Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions NativeAppTemplate/Data/DataState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
14 changes: 3 additions & 11 deletions NativeAppTemplate/UI/Shop List/ShopListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions NativeAppTemplateTests/Data/DataStateListContentTest.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading