Skip to content

Commit c0a3c26

Browse files
authored
Merge branch 'main' into feat/test-interface
2 parents d30805d + 59b2ad1 commit c0a3c26

File tree

6 files changed

+94
-134
lines changed

6 files changed

+94
-134
lines changed

Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/DetailAtoms.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ struct IsInMyListAtom: ValueAtom, Hashable {
55

66
func value(context: Context) -> Bool {
77
let myList = context.watch(MyListAtom())
8-
return myList.contains(movie)
8+
return myList.movies.contains(movie)
99
}
1010
}
1111

Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/MoviesAtoms.swift

Lines changed: 52 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,77 @@
11
import Atoms
22
import Foundation
33

4-
struct FirstPageAtom: ThrowingTaskAtom, Hashable {
5-
func value(context: Context) async throws -> PagedResponse<Movie> {
6-
let api = context.watch(APIClientAtom())
7-
let filter = context.watch(FilterAtom())
8-
9-
return try await api.getMovies(filter: filter, page: 1)
4+
@MainActor
5+
final class MovieLoader: ObservableObject {
6+
@Published
7+
private(set) var pages = AsyncPhase<[PagedResponse<Movie>], Error>.suspending
8+
private let api: APIClientProtocol
9+
let filter: Filter
10+
11+
init(api: APIClientProtocol, filter: Filter) {
12+
self.api = api
13+
self.filter = filter
1014
}
11-
}
1215

13-
struct NextPagesAtom: StateAtom, Hashable {
14-
func defaultValue(context: Context) -> [PagedResponse<Movie>] {
15-
// Purges when the first page is updated.
16-
context.watch(FirstPageAtom())
17-
return []
16+
func refresh() async {
17+
do {
18+
pages = .suspending
19+
20+
let page = try await api.getMovies(filter: filter, page: 1)
21+
pages = .success([page])
22+
}
23+
catch {
24+
pages = .failure(error)
25+
}
1826
}
19-
}
2027

21-
struct LoadNextAtom: ValueAtom, Hashable {
22-
@MainActor
23-
struct Action {
24-
let context: AtomContext
28+
func loadNext() async {
29+
guard let currentPages = pages.value, let lastPage = currentPages.last?.page else {
30+
return
31+
}
2532

26-
func callAsFunction() async {
27-
let api = context.read(APIClientAtom())
28-
let filter = context.read(FilterAtom())
29-
let currentPage = context.read(NextPagesAtom()).last?.page ?? 1
30-
let nextPage = try? await api.getMovies(filter: filter, page: currentPage + 1)
33+
let nextPage = try? await api.getMovies(filter: filter, page: lastPage + 1)
3134

32-
if let nextPage = nextPage {
33-
context[NextPagesAtom()].append(nextPage)
34-
}
35+
guard let nextPage = nextPage else {
36+
return
3537
}
36-
}
3738

38-
func value(context: Context) -> Action {
39-
Action(context: context)
39+
pages = .success(currentPages + [nextPage])
4040
}
4141
}
4242

43-
struct FilterAtom: StateAtom, Hashable {
44-
func defaultValue(context: Context) -> Filter {
45-
.nowPlaying
43+
@MainActor
44+
final class MyList: ObservableObject {
45+
@Published
46+
private(set) var movies = [Movie]()
47+
48+
func insert(movie: Movie) {
49+
if let index = movies.firstIndex(of: movie) {
50+
movies.remove(at: index)
51+
}
52+
else {
53+
movies.append(movie)
54+
}
4655
}
4756
}
4857

49-
struct MyListAtom: StateAtom, Hashable, KeepAlive {
50-
func defaultValue(context: Context) -> [Movie] {
51-
[]
58+
struct MovieLoaderAtom: ObservableObjectAtom, Hashable {
59+
func object(context: Context) -> MovieLoader {
60+
let api = context.watch(APIClientAtom())
61+
let filter = context.watch(FilterAtom())
62+
return MovieLoader(api: api, filter: filter)
5263
}
5364
}
5465

55-
struct MyListInsertAtom: ValueAtom, Hashable {
56-
@MainActor
57-
struct Action {
58-
let context: AtomContext
59-
60-
func callAsFunction(movie: Movie) {
61-
let myList = MyListAtom()
62-
63-
if let index = context[myList].firstIndex(of: movie) {
64-
context[myList].remove(at: index)
65-
}
66-
else {
67-
context[myList].append(movie)
68-
}
69-
}
66+
struct MyListAtom: ObservableObjectAtom, Hashable, KeepAlive {
67+
func object(context: Context) -> MyList {
68+
MyList()
7069
}
70+
}
7171

72-
func value(context: Context) -> Action {
73-
Action(context: context)
72+
struct FilterAtom: StateAtom, Hashable {
73+
func defaultValue(context: Context) -> Filter {
74+
.nowPlaying
7475
}
7576
}
7677

Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/DetailScreen.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import SwiftUI
44
struct DetailScreen: View {
55
let movie: Movie
66

7-
@Watch(MyListInsertAtom())
8-
var myListInsert
7+
@WatchStateObject(MyListAtom())
8+
var myList
99

1010
@ViewContext
1111
var context
@@ -98,7 +98,7 @@ struct DetailScreen: View {
9898
let isOn = context.watch(IsInMyListAtom(movie: movie))
9999

100100
Button {
101-
myListInsert(movie: movie)
101+
myList.insert(movie: movie)
102102
} label: {
103103
MyListButtonLabel(isOn: isOn)
104104
}

Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/MoviesScreen.swift

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,12 @@ import Atoms
22
import SwiftUI
33

44
struct MoviesScreen: View {
5-
@Watch(FirstPageAtom())
6-
var firstPage
7-
8-
@Watch(NextPagesAtom())
9-
var nextPages
5+
@WatchStateObject(MovieLoaderAtom())
6+
var loader
107

118
@WatchState(SearchQueryAtom())
129
var searchQuery
1310

14-
@Watch(LoadNextAtom())
15-
var loadNext
16-
1711
@ViewContext
1812
var context
1913

@@ -34,9 +28,8 @@ struct MoviesScreen: View {
3428
Section {
3529
FilterPicker()
3630

37-
Suspense(firstPage) { firstPage in
38-
let pages = [firstPage] + nextPages
39-
31+
switch loader.pages {
32+
case .success(let pages):
4033
ForEach(pages, id: \.page) { response in
4134
pageIndex(current: response.page, total: response.totalPages)
4235

@@ -47,13 +40,15 @@ struct MoviesScreen: View {
4740

4841
if let last = pages.last, last.hasNextPage {
4942
ProgressRow().task {
50-
await loadNext()
43+
await loader.loadNext()
5144
}
5245
}
53-
} suspending: {
54-
ProgressRow()
55-
} catch: { _ in
46+
47+
case .failure:
5648
CaveatRow(text: "Failed to get the data.")
49+
50+
case .suspending:
51+
ProgressRow()
5752
}
5853
}
5954
}
@@ -71,8 +66,11 @@ struct MoviesScreen: View {
7166
.onSubmit(of: .search) { [$isShowingSearchScreen] in
7267
$isShowingSearchScreen.wrappedValue = true
7368
}
74-
.refreshable { [context] in
75-
await context.refresh(FirstPageAtom())
69+
.task(id: loader.filter) {
70+
await loader.refresh()
71+
}
72+
.refreshable { [loader] in
73+
await loader.refresh()
7674
}
7775
.background {
7876
NavigationLink(

Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MyMovieList.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@ import Atoms
22
import SwiftUI
33

44
struct MyMovieList: View {
5-
@Watch(MyListAtom())
5+
@WatchStateObject(MyListAtom())
66
var myList
77

88
var onSelect: (Movie) -> Void
99

1010
var body: some View {
11-
if myList.isEmpty {
11+
if myList.movies.isEmpty {
1212
emptyContent
1313
}
1414
else {
1515
ScrollView(.horizontal, showsIndicators: false) {
1616
LazyHStack {
17-
ForEach(myList, id: \.id) { movie in
17+
ForEach(myList.movies, id: \.id) { movie in
1818
item(movie: movie)
1919
}
2020
}

Examples/Packages/iOS/Tests/ExampleMovieDBTests/ExampleMovieDBTests.swift

Lines changed: 20 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ final class ExampleMovieDBTests: XCTestCase {
2929
XCTAssertEqual(failurePhase.error as? URLError, error)
3030
}
3131

32-
func testFirstPageAtom() async {
32+
func testMovieLoader() async {
3333
let apiClient = MockAPIClient()
34-
let atom = FirstPageAtom()
34+
let atom = MovieLoaderAtom()
3535
let context = AtomTestContext()
3636

3737
context.override(APIClientAtom()) { _ in apiClient }
@@ -44,85 +44,46 @@ final class ExampleMovieDBTests: XCTestCase {
4444

4545
apiClient.filteredMovieResponse = .success(expected)
4646

47-
let successPhase = await AsyncPhase(context.watch(atom).result)
47+
await context.watch(atom).refresh()
4848

49-
XCTAssertEqual(successPhase.value, expected)
49+
XCTAssertEqual(context.watch(atom).pages.value, [expected])
50+
51+
await context.watch(atom).loadNext()
52+
53+
XCTAssertEqual(context.watch(atom).pages.value, [expected, expected])
5054

5155
context.reset(atom)
5256
apiClient.filteredMovieResponse = .failure(error)
5357

54-
let failurePhase = await AsyncPhase(context.watch(atom).result)
58+
await context.watch(atom).refresh()
5559

56-
XCTAssertEqual(failurePhase.error as? URLError, error)
60+
XCTAssertEqual(context.watch(atom).pages.error as? URLError, error)
5761
}
5862
}
5963

60-
func testNextPagesAtom() {
61-
let atom = NextPagesAtom()
62-
let context = AtomTestContext()
63-
let pages = [
64-
PagedResponse<Movie>(page: 1, totalPages: 100, results: [])
65-
]
66-
67-
XCTAssertEqual(context.watch(atom), [])
68-
69-
context[atom] = pages
70-
71-
XCTAssertEqual(context.watch(atom), pages)
72-
73-
context.reset(FirstPageAtom())
74-
75-
XCTAssertEqual(context.watch(atom), [])
76-
}
77-
78-
func testLoadNextAtom() async {
79-
let apiClient = MockAPIClient()
80-
let atom = LoadNextAtom()
81-
let context = AtomTestContext()
82-
83-
context.override(APIClientAtom()) { _ in apiClient }
84-
85-
apiClient.filteredMovieResponse = .success(.stub())
86-
87-
let loadNext = context.watch(atom)
88-
89-
XCTAssertEqual(context.watch(NextPagesAtom()), [])
90-
91-
await loadNext()
92-
93-
XCTAssertEqual(context.watch(NextPagesAtom()), [.stub()])
94-
95-
await loadNext()
96-
97-
XCTAssertEqual(context.watch(NextPagesAtom()), [.stub(), .stub()])
98-
}
99-
100-
func testMyListInsertAtom() {
101-
let atom = MyListInsertAtom()
64+
func testMyListAtom() {
65+
let atom = MyListAtom()
10266
let context = AtomTestContext()
103-
let action = context.watch(atom)
10467

105-
XCTAssertEqual(context.watch(MyListAtom()), [])
68+
XCTAssertEqual(context.watch(atom).movies, [])
10669

107-
action(movie: .stub(id: 0))
70+
context.watch(atom).insert(movie: .stub(id: 0))
10871

109-
XCTAssertEqual(context.watch(MyListAtom()), [.stub(id: 0)])
72+
XCTAssertEqual(context.watch(atom).movies, [.stub(id: 0)])
11073

111-
action(movie: .stub(id: 1))
74+
context.watch(atom).insert(movie: .stub(id: 1))
11275

113-
XCTAssertEqual(context.watch(MyListAtom()), [.stub(id: 0), .stub(id: 1)])
76+
XCTAssertEqual(context.watch(atom).movies, [.stub(id: 0), .stub(id: 1)])
11477

115-
action(movie: .stub(id: 0))
78+
context.watch(atom).insert(movie: .stub(id: 0))
11679

117-
XCTAssertEqual(context.watch(MyListAtom()), [.stub(id: 1)])
80+
XCTAssertEqual(context.watch(atom).movies, [.stub(id: 1)])
11881
}
11982

12083
func testIsInMyListAtom() {
12184
let context = AtomTestContext()
12285

123-
XCTAssertFalse(context.watch(IsInMyListAtom(movie: .stub(id: 0))))
124-
125-
context[MyListAtom()].append(.stub(id: 0))
86+
context.watch(MyListAtom()).insert(movie: .stub(id: 0))
12687

12788
XCTAssertTrue(context.watch(IsInMyListAtom(movie: .stub(id: 0))))
12889
XCTAssertFalse(context.watch(IsInMyListAtom(movie: .stub(id: 1))))

0 commit comments

Comments
 (0)