diff --git a/CHANGELOG.md b/CHANGELOG.md index b2d86bb..a8049aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- List screens switch on `DataState.listContent(isEmpty:)`, where a reload with data on screen is the same `.list` case as loaded data instead of a `switch` branch of its own. A separate branch makes SwiftUI rebuild the list on every reload, re-register its `navigationDestination` and re-run its `.task`; in MyTurnTagCreator that turned a reload after each tag change into a loop that hit the API's rate limit. Nothing here reloads that way yet, so this removes the trap rather than a live bug (Shops, number tags) - Error messages are localized (English and Japanese). A request that fails without the API's `error_message` shows "Something went wrong while communicating with the server. Please try again later." (`NATIVEAPPTEMPLATE-2001`); developer-only errors (`NATIVEAPPTEMPLATE-1001`, `2002`–`2008`, `4001`–`4004`) show "An unexpected error occurred." The API's `error_message` is shown without the " [Status: nnn]" suffix. Error codes are unchanged; status codes, field names and underlying errors go to the log (`CodedError.debugDescription`) ### Fixed diff --git a/NativeAppTemplate/Data/DataState.swift b/NativeAppTemplate/Data/DataState.swift index 159ef24..0ac4f43 100644 --- a/NativeAppTemplate/Data/DataState.swift +++ b/NativeAppTemplate/Data/DataState.swift @@ -9,3 +9,31 @@ enum DataState { case hasData case failed } + +/// What a list screen shows for its `DataState`. +enum ListContent { + case loading + case list + case failed +} + +extension DataState { + /// A reload keeps the list already on screen instead of blinking a full-screen + /// LoadingView over it. It is the same `.list` case as `.hasData` on purpose: a + /// separate `switch` branch is a different view to SwiftUI, so the list would be + /// rebuilt on every reload, re-registering its `navigationDestination` and re-running + /// its `.task`. In MyTurnTagCreator that turned a reload after each tag change into a + /// loop that hit the API's rate limit. + func listContent(isEmpty: Bool) -> ListContent { + switch self { + case .initial: + .loading + case .loading: + isEmpty ? .loading : .list + case .hasData: + .list + case .failed: + .failed + } + } +} diff --git a/NativeAppTemplate/UI/Shop List/ShopListView.swift b/NativeAppTemplate/UI/Shop List/ShopListView.swift index f9f0a72..1f379e7 100644 --- a/NativeAppTemplate/UI/Shop List/ShopListView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopListView.swift @@ -62,18 +62,10 @@ extension ShopListView { private extension ShopListView { var contentView: some View { @ViewBuilder var contentView: some View { - switch viewModel.state { - case .initial: - LoadingView() + switch viewModel.state.listContent(isEmpty: viewModel.isEmpty) { case .loading: - // Keep showing the existing list while refreshing so re-appearance - // reloads don't blink a full-screen LoadingView over loaded data. - if viewModel.isEmpty { - LoadingView() - } else { - shopListView - } - case .hasData: + LoadingView() + case .list: shopListView case .failed: reloadView diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift index 8acff23..3ee0e9a 100644 --- a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift @@ -30,18 +30,10 @@ private extension ItemTagListView { if viewModel.isBusy { LoadingView() } else { - switch viewModel.state { - case .initial: - LoadingView() + switch viewModel.state.listContent(isEmpty: viewModel.isEmpty) { case .loading: - // Keep showing the existing list while refreshing so re-appearance - // reloads don't blink a full-screen LoadingView over loaded data. - if viewModel.isEmpty { - LoadingView() - } else { - itemTagListView - } - case .hasData: + LoadingView() + case .list: itemTagListView case .failed: reloadView diff --git a/NativeAppTemplateTests/Data/DataStateListContentTest.swift b/NativeAppTemplateTests/Data/DataStateListContentTest.swift new file mode 100644 index 0000000..8b2819a --- /dev/null +++ b/NativeAppTemplateTests/Data/DataStateListContentTest.swift @@ -0,0 +1,39 @@ +// +// DataStateListContentTest.swift +// NativeAppTemplate +// + +@testable import NativeAppTemplate +import Testing + +/// List screens switch on `listContent(isEmpty:)`. A reload of a list already on screen +/// must stay `.list`, the same case as `.hasData`: as a branch of its own, SwiftUI rebuilds +/// the list on every reload and re-runs its `.task`. In MyTurnTagCreator 2.1.0 that made the +/// Shops tab reload about twice a second while a shop was open. +@Suite +struct DataStateListContentTest { + @Test + func reloadingWithDataKeepsTheList() { + // A reload with shops on screen (.loading), then .hasData: both the one `.list` case. + #expect(DataState.loading.listContent(isEmpty: false) == .list) + #expect(DataState.hasData.listContent(isEmpty: false) == .list) + } + + @Test + func firstLoadShowsLoading() { + #expect(DataState.initial.listContent(isEmpty: true) == .loading) + #expect(DataState.loading.listContent(isEmpty: true) == .loading) + } + + @Test + func loadedEmptyListIsStillTheList() { + // An empty result is shown by the list view itself (e.g. "no shops yet") + #expect(DataState.hasData.listContent(isEmpty: true) == .list) + } + + @Test + func failureShowsTheErrorView() { + #expect(DataState.failed.listContent(isEmpty: true) == .failed) + #expect(DataState.failed.listContent(isEmpty: false) == .failed) + } +}