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
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ struct IsInMyListAtom: ValueAtom, Hashable {

func value(context: Context) -> Bool {
let myList = context.watch(MyListAtom())
return myList.contains(movie)
return myList.movies.contains(movie)
}
}

Expand Down
103 changes: 52 additions & 51 deletions Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/MoviesAtoms.swift
Original file line number Diff line number Diff line change
@@ -1,76 +1,77 @@
import Atoms
import Foundation

struct FirstPageAtom: ThrowingTaskAtom, Hashable {
func value(context: Context) async throws -> PagedResponse<Movie> {
let api = context.watch(APIClientAtom())
let filter = context.watch(FilterAtom())

return try await api.getMovies(filter: filter, page: 1)
@MainActor
final class MovieLoader: ObservableObject {
@Published
private(set) var pages = AsyncPhase<[PagedResponse<Movie>], Error>.suspending
private let api: APIClientProtocol
let filter: Filter

init(api: APIClientProtocol, filter: Filter) {
self.api = api
self.filter = filter
}
}

struct NextPagesAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> [PagedResponse<Movie>] {
// Purges when the first page is updated.
context.watch(FirstPageAtom())
return []
func refresh() async {
do {
pages = .suspending

let page = try await api.getMovies(filter: filter, page: 1)
pages = .success([page])
}
catch {
pages = .failure(error)
}
}
}

struct LoadNextAtom: ValueAtom, Hashable {
@MainActor
struct Action {
let context: AtomContext
func loadNext() async {
guard let currentPages = pages.value, let lastPage = currentPages.last?.page else {
return
}

func callAsFunction() async {
let api = context.read(APIClientAtom())
let filter = context.read(FilterAtom())
let currentPage = context.read(NextPagesAtom()).last?.page ?? 1
let nextPage = try? await api.getMovies(filter: filter, page: currentPage + 1)
let nextPage = try? await api.getMovies(filter: filter, page: lastPage + 1)

if let nextPage = nextPage {
context[NextPagesAtom()].append(nextPage)
}
guard let nextPage = nextPage else {
return
}
}

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

struct FilterAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> Filter {
.nowPlaying
@MainActor
final class MyList: ObservableObject {
@Published
private(set) var movies = [Movie]()

func insert(movie: Movie) {
if let index = movies.firstIndex(of: movie) {
movies.remove(at: index)
}
else {
movies.append(movie)
}
}
}

struct MyListAtom: StateAtom, Hashable, KeepAlive {
func defaultValue(context: Context) -> [Movie] {
[]
struct MovieLoaderAtom: ObservableObjectAtom, Hashable {
func object(context: Context) -> MovieLoader {
let api = context.watch(APIClientAtom())
let filter = context.watch(FilterAtom())
return MovieLoader(api: api, filter: filter)
}
}

struct MyListInsertAtom: ValueAtom, Hashable {
@MainActor
struct Action {
let context: AtomContext

func callAsFunction(movie: Movie) {
let myList = MyListAtom()

if let index = context[myList].firstIndex(of: movie) {
context[myList].remove(at: index)
}
else {
context[myList].append(movie)
}
}
struct MyListAtom: ObservableObjectAtom, Hashable, KeepAlive {
func object(context: Context) -> MyList {
MyList()
}
}

func value(context: Context) -> Action {
Action(context: context)
struct FilterAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> Filter {
.nowPlaying
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import SwiftUI
struct DetailScreen: View {
let movie: Movie

@Watch(MyListInsertAtom())
var myListInsert
@WatchStateObject(MyListAtom())
var myList

@ViewContext
var context
Expand Down Expand Up @@ -98,7 +98,7 @@ struct DetailScreen: View {
let isOn = context.watch(IsInMyListAtom(movie: movie))

Button {
myListInsert(movie: movie)
myList.insert(movie: movie)
} label: {
MyListButtonLabel(isOn: isOn)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,12 @@ import Atoms
import SwiftUI

struct MoviesScreen: View {
@Watch(FirstPageAtom())
var firstPage

@Watch(NextPagesAtom())
var nextPages
@WatchStateObject(MovieLoaderAtom())
var loader

@WatchState(SearchQueryAtom())
var searchQuery

@Watch(LoadNextAtom())
var loadNext

@ViewContext
var context

Expand All @@ -34,9 +28,8 @@ struct MoviesScreen: View {
Section {
FilterPicker()

Suspense(firstPage) { firstPage in
let pages = [firstPage] + nextPages

switch loader.pages {
case .success(let pages):
ForEach(pages, id: \.page) { response in
pageIndex(current: response.page, total: response.totalPages)

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

if let last = pages.last, last.hasNextPage {
ProgressRow().task {
await loadNext()
await loader.loadNext()
}
}
} suspending: {
ProgressRow()
} catch: { _ in

case .failure:
CaveatRow(text: "Failed to get the data.")

case .suspending:
ProgressRow()
}
}
}
Expand All @@ -71,8 +66,11 @@ struct MoviesScreen: View {
.onSubmit(of: .search) { [$isShowingSearchScreen] in
$isShowingSearchScreen.wrappedValue = true
}
.refreshable { [context] in
await context.refresh(FirstPageAtom())
.task(id: loader.filter) {
await loader.refresh()
}
.refreshable { [loader] in
await loader.refresh()
}
.background {
NavigationLink(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ import Atoms
import SwiftUI

struct MyMovieList: View {
@Watch(MyListAtom())
@WatchStateObject(MyListAtom())
var myList

var onSelect: (Movie) -> Void

var body: some View {
if myList.isEmpty {
if myList.movies.isEmpty {
emptyContent
}
else {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(myList, id: \.id) { movie in
ForEach(myList.movies, id: \.id) { movie in
item(movie: movie)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ final class ExampleMovieDBTests: XCTestCase {
XCTAssertEqual(failurePhase.error as? URLError, error)
}

func testFirstPageAtom() async {
func testMovieLoader() async {
let apiClient = MockAPIClient()
let atom = FirstPageAtom()
let atom = MovieLoaderAtom()
let context = AtomTestContext()

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

apiClient.filteredMovieResponse = .success(expected)

let successPhase = await AsyncPhase(context.watch(atom).result)
await context.watch(atom).refresh()

XCTAssertEqual(successPhase.value, expected)
XCTAssertEqual(context.watch(atom).pages.value, [expected])

await context.watch(atom).loadNext()

XCTAssertEqual(context.watch(atom).pages.value, [expected, expected])

context.reset(atom)
apiClient.filteredMovieResponse = .failure(error)

let failurePhase = await AsyncPhase(context.watch(atom).result)
await context.watch(atom).refresh()

XCTAssertEqual(failurePhase.error as? URLError, error)
XCTAssertEqual(context.watch(atom).pages.error as? URLError, error)
}
}

func testNextPagesAtom() {
let atom = NextPagesAtom()
let context = AtomTestContext()
let pages = [
PagedResponse<Movie>(page: 1, totalPages: 100, results: [])
]

XCTAssertEqual(context.watch(atom), [])

context[atom] = pages

XCTAssertEqual(context.watch(atom), pages)

context.reset(FirstPageAtom())

XCTAssertEqual(context.watch(atom), [])
}

func testLoadNextAtom() async {
let apiClient = MockAPIClient()
let atom = LoadNextAtom()
let context = AtomTestContext()

context.override(APIClientAtom()) { _ in apiClient }

apiClient.filteredMovieResponse = .success(.stub())

let loadNext = context.watch(atom)

XCTAssertEqual(context.watch(NextPagesAtom()), [])

await loadNext()

XCTAssertEqual(context.watch(NextPagesAtom()), [.stub()])

await loadNext()

XCTAssertEqual(context.watch(NextPagesAtom()), [.stub(), .stub()])
}

func testMyListInsertAtom() {
let atom = MyListInsertAtom()
func testMyListAtom() {
let atom = MyListAtom()
let context = AtomTestContext()
let action = context.watch(atom)

XCTAssertEqual(context.watch(MyListAtom()), [])
XCTAssertEqual(context.watch(atom).movies, [])

action(movie: .stub(id: 0))
context.watch(atom).insert(movie: .stub(id: 0))

XCTAssertEqual(context.watch(MyListAtom()), [.stub(id: 0)])
XCTAssertEqual(context.watch(atom).movies, [.stub(id: 0)])

action(movie: .stub(id: 1))
context.watch(atom).insert(movie: .stub(id: 1))

XCTAssertEqual(context.watch(MyListAtom()), [.stub(id: 0), .stub(id: 1)])
XCTAssertEqual(context.watch(atom).movies, [.stub(id: 0), .stub(id: 1)])

action(movie: .stub(id: 0))
context.watch(atom).insert(movie: .stub(id: 0))

XCTAssertEqual(context.watch(MyListAtom()), [.stub(id: 1)])
XCTAssertEqual(context.watch(atom).movies, [.stub(id: 1)])
}

func testIsInMyListAtom() {
let context = AtomTestContext()

XCTAssertFalse(context.watch(IsInMyListAtom(movie: .stub(id: 0))))

context[MyListAtom()].append(.stub(id: 0))
context.watch(MyListAtom()).insert(movie: .stub(id: 0))

XCTAssertTrue(context.watch(IsInMyListAtom(movie: .stub(id: 0))))
XCTAssertFalse(context.watch(IsInMyListAtom(movie: .stub(id: 1))))
Expand Down