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
Binary file modified .DS_Store
Binary file not shown.
Binary file modified Stream-iOS/.DS_Store
Binary file not shown.
12 changes: 12 additions & 0 deletions Stream-iOS/Stream-iOS/Features/Characters/CharacterListState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// CharacterListState.swift
// Stream-iOS
//
// Created by Ullash Podder on 20/07/2026.
//
enum CharacterListState: Equatable {
case idle
case loading
case loaded
case error(String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//
// CharacterListView.swift
// Stream-iOS
//
// Created by Ullash Podder on 20/07/2026.
//

Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// CharacterListViewModel.swift
// Stream-iOS
//
// Created by Ullash Podder on 20/07/2026.
//

import Foundation

@MainActor
@Observable
final class CharacterListViewModel {
private(set) var characters: [CharacterDTO] = []
private(set) var state: CharacterListState = .idle

private let repository: CharacterRepository
private var currentPage = 0
private var totalPages = Int.max // unknown until first response
private var isLoading = false // the dedupe guard

init(repository: CharacterRepository) {
self.repository = repository
}

func loadNextPageIfNeeded() async {
// Guard 1: don't start a fetch while one is running
guard !isLoading else { return }
// Guard 2: don't fetch past the last page
guard currentPage < totalPages else { return }

isLoading = true
state = .loading
let nextPage = currentPage + 1

do {
let response = try await repository.fetchCharacters(page: nextPage)
characters.append(contentsOf: response.results)
currentPage = nextPage
totalPages = response.info.pages
state = .loaded
} catch {
state = .error("Could not load characters.")
}

isLoading = false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//
// CharacterRowView.swift
// Stream-iOS
//
// Created by Ullash Podder on 20/07/2026.
//

58 changes: 58 additions & 0 deletions Stream-iOS/Stream-iOSTests/CharacterDecodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,63 @@ struct CharacterDecodingTests {

#expect(dto.status == .unknown)
}
func decodedFixturePage() throws -> PagedResponse<CharacterDTO> {
let data = try Fixture.load("character_page1")
let response = try JSONDecoder().decode(PagedResponse<CharacterDTO>.self, from: data)
return response
}
// when several calls to loadNextPageIfNeeded() overlap in time, exactly one fetch reaches the repository.
@MainActor @Test func loadsFirstPageAndPopulatesCharacters() async throws{
// arrange
let mockRepository = MockCharacterRepository()
mockRepository.result = .success( try decodedFixturePage())
let sut = CharacterListViewModel(repository: mockRepository)
// act
await sut.loadNextPageIfNeeded()
// assert
#expect(sut.state == .loaded)
#expect(sut.characters.count == 20)
#expect(mockRepository.requestedPages == [1])
}
@MainActor @Test func loadsSecondPageAndAppends() async throws{
// arrange
let mockRepository = MockCharacterRepository()
mockRepository.result = .success( try decodedFixturePage())
let sut = CharacterListViewModel(repository: mockRepository)
// act
await sut.loadNextPageIfNeeded()
// second call
await sut.loadNextPageIfNeeded()
// assert
#expect(sut.state == .loaded)
#expect(sut.characters.count == 40)
#expect(mockRepository.requestedPages == [1,2])
}
@MainActor @Test func concurrentLoadsRequestPageOnlyOnce() async throws{
// arrange
let mockRepository = MockCharacterRepository()
mockRepository.result = .success( try decodedFixturePage())
let sut = CharacterListViewModel(repository: mockRepository)
// act
await withTaskGroup (of:Void.self) { group in
for _ in 0..<3 {
group.addTask { await sut.loadNextPageIfNeeded() }
}
}
// assert
#expect(sut.state == .loaded)
#expect(sut.characters.count == 20)
#expect(mockRepository.requestedPages == [1])
}
@MainActor @Test func failedLoadSetsErrorStateAndKeepsCharactersEmpty() async throws {
let mockRepository = MockCharacterRepository()
mockRepository.result = .failure(APIError.httpStatus(500))
let sut = CharacterListViewModel(repository: mockRepository)

await sut.loadNextPageIfNeeded()

#expect(sut.characters.isEmpty)
// assert state is .error
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ final class MockCharacterRepository: CharacterRepository, @unchecked Sendable {

func fetchCharacters(page: Int) async throws -> PagedResponse<CharacterDTO> {
requestedPages.append(page)
try await Task.sleep(for: .milliseconds(50))
return try result.get()
}
}
Loading