Generic Statuses list

This commit is contained in:
Thomas Ricouard 2022-12-19 07:17:01 +01:00
parent 4c3809a95b
commit e2455a472e
7 changed files with 173 additions and 109 deletions

View file

@ -21,8 +21,7 @@ public struct AccountDetailView: View {
ScrollView { ScrollView {
LazyVStack { LazyVStack {
headerView headerView
statusesView StatusesListView(fetcher: viewModel)
.padding(.horizontal, 16)
} }
} }
.edgesIgnoringSafeArea(.top) .edgesIgnoringSafeArea(.top)
@ -46,47 +45,6 @@ public struct AccountDetailView: View {
} }
} }
@ViewBuilder
private var statusesView: some View {
switch viewModel.statusesState {
case .loading:
ForEach(Status.placeholders()) { status in
StatusRowView(status: status)
.redacted(reason: .placeholder)
.shimmering()
Divider()
}
case let .error(error):
Text(error.localizedDescription)
case let .display(statuses, nextPageState):
ForEach(statuses) { status in
StatusRowView(status: status)
Divider()
.padding(.bottom, DS.Constants.layoutPadding)
}
switch nextPageState {
case .hasNextPage:
loadingRow
.onAppear {
Task {
await viewModel.loadNextPage()
}
}
case .loadingNextPage:
loadingRow
}
}
}
private var loadingRow: some View {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
} }
struct AccountDetailView_Previews: PreviewProvider { struct AccountDetailView_Previews: PreviewProvider {

View file

@ -1,24 +1,17 @@
import SwiftUI import SwiftUI
import Network import Network
import Models import Models
import Status
@MainActor @MainActor
class AccountDetailViewModel: ObservableObject { class AccountDetailViewModel: ObservableObject, StatusesFetcher {
let accountId: String let accountId: String
var client: Client = .init(server: "") var client: Client = .init(server: "")
enum State { enum State {
case loading, data(account: Account), error(error: Error) case loading, data(account: Account), error(error: Error)
} }
enum StatusesState {
enum PagingState {
case hasNextPage, loadingNextPage
}
case loading
case display(statuses: [Status], nextPageState: StatusesState.PagingState)
case error(error: Error)
}
@Published var state: State = .loading @Published var state: State = .loading
@Published var statusesState: StatusesState = .loading @Published var statusesState: StatusesState = .loading
@ -52,7 +45,7 @@ class AccountDetailViewModel: ObservableObject {
} }
} }
func loadNextPage() async { func fetchNextPage() async {
do { do {
guard let lastId = statuses.last?.id else { return } guard let lastId = statuses.last?.id else { return }
statusesState = .display(statuses: statuses, nextPageState: .loadingNextPage) statusesState = .display(statuses: statuses, nextPageState: .loadingNextPage)

View file

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1420"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Status"
BuildableName = "Status"
BlueprintName = "Status"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "StatusTests"
BuildableName = "StatusTests"
BlueprintName = "StatusTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Status"
BuildableName = "Status"
BlueprintName = "Status"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -17,6 +17,7 @@ let package = Package(
.package(name: "Models", path: "../Models"), .package(name: "Models", path: "../Models"),
.package(name: "Routeur", path: "../Routeur"), .package(name: "Routeur", path: "../Routeur"),
.package(name: "DesignSystem", path: "../DesignSystem"), .package(name: "DesignSystem", path: "../DesignSystem"),
.package(url: "https://github.com/markiv/SwiftUI-Shimmer", exact: "1.1.0")
], ],
targets: [ targets: [
.target( .target(
@ -25,6 +26,7 @@ let package = Package(
.product(name: "Models", package: "Models"), .product(name: "Models", package: "Models"),
.product(name: "Routeur", package: "Routeur"), .product(name: "Routeur", package: "Routeur"),
.product(name: "DesignSystem", package: "DesignSystem"), .product(name: "DesignSystem", package: "DesignSystem"),
.product(name: "Shimmer", package: "SwiftUI-Shimmer")
]), ]),
] ]
) )

View file

@ -0,0 +1,72 @@
import SwiftUI
import Models
import Shimmer
import DesignSystem
public enum StatusesState {
public enum PagingState {
case hasNextPage, loadingNextPage
}
case loading
case display(statuses: [Status], nextPageState: StatusesState.PagingState)
case error(error: Error)
}
@MainActor
public protocol StatusesFetcher: ObservableObject {
var statusesState: StatusesState { get }
func fetchStatuses() async
func fetchNextPage() async
}
public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
@ObservedObject private var fetcher: Fetcher
public init(fetcher: Fetcher) {
self.fetcher = fetcher
}
public var body: some View {
Group {
switch fetcher.statusesState {
case .loading:
ForEach(Status.placeholders()) { status in
StatusRowView(status: status)
.redacted(reason: .placeholder)
.shimmering()
Divider()
.padding(.bottom, DS.Constants.layoutPadding)
}
case let .error(error):
Text(error.localizedDescription)
case let .display(statuses, nextPageState):
ForEach(statuses) { status in
StatusRowView(status: status)
Divider()
.padding(.bottom, DS.Constants.layoutPadding)
}
switch nextPageState {
case .hasNextPage:
loadingRow
.onAppear {
Task {
await fetcher.fetchNextPage()
}
}
case .loadingNextPage:
loadingRow
}
}
}
.padding(.horizontal, 16)
}
private var loadingRow: some View {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}

View file

@ -12,34 +12,11 @@ public struct TimelineView: View {
public init() {} public init() {}
public var body: some View { public var body: some View {
List { ScrollView {
switch viewModel.state { LazyVStack {
case .loading: StatusesListView(fetcher: viewModel)
ForEach(Status.placeholders()) { placeholder in
StatusRowView(status: placeholder)
.redacted(reason: .placeholder)
.shimmering()
}
case let .error(error):
Text(error.localizedDescription)
case let .display(statuses, nextPageState):
ForEach(statuses) { status in
StatusRowView(status: status)
}
switch nextPageState {
case .hasNextPage:
loadingRow
.onAppear {
Task {
await viewModel.loadNextPage()
}
}
case .loadingNextPage:
loadingRow
}
} }
} }
.listStyle(.plain)
.navigationTitle(viewModel.timeline.rawValue) .navigationTitle(viewModel.timeline.rawValue)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@ -50,22 +27,15 @@ public struct TimelineView: View {
.task { .task {
viewModel.client = client viewModel.client = client
if !didAppear { if !didAppear {
await viewModel.refreshTimeline() await viewModel.fetchStatuses()
didAppear = true didAppear = true
} }
} }
.refreshable { .refreshable {
await viewModel.refreshTimeline() await viewModel.fetchStatuses()
} }
} }
private var loadingRow: some View {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
private var timelineFilterButton: some View { private var timelineFilterButton: some View {
Menu { Menu {

View file

@ -1,18 +1,10 @@
import SwiftUI import SwiftUI
import Network import Network
import Models import Models
import Status
@MainActor @MainActor
class TimelineViewModel: ObservableObject { class TimelineViewModel: ObservableObject, StatusesFetcher {
enum State {
enum PagingState {
case hasNextPage, loadingNextPage
}
case loading
case display(statuses: [Status], nextPageState: State.PagingState)
case error(error: Error)
}
enum TimelineFilter: String, CaseIterable { enum TimelineFilter: String, CaseIterable {
case pub = "Public" case pub = "Public"
case home = "Home" case home = "Home"
@ -33,12 +25,12 @@ class TimelineViewModel: ObservableObject {
private var statuses: [Status] = [] private var statuses: [Status] = []
@Published var state: State = .loading @Published var statusesState: StatusesState = .loading
@Published var timeline: TimelineFilter = .pub { @Published var timeline: TimelineFilter = .pub {
didSet { didSet {
if oldValue != timeline { if oldValue != timeline {
Task { Task {
await refreshTimeline() await fetchStatuses()
} }
} }
} }
@ -48,25 +40,25 @@ class TimelineViewModel: ObservableObject {
client.server client.server
} }
func refreshTimeline() async { func fetchStatuses() async {
do { do {
state = .loading statusesState = .loading
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil)) statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil))
state = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} catch { } catch {
state = .error(error: error) statusesState = .error(error: error)
} }
} }
func loadNextPage() async { func fetchNextPage() async {
do { do {
guard let lastId = statuses.last?.id else { return } guard let lastId = statuses.last?.id else { return }
state = .display(statuses: statuses, nextPageState: .loadingNextPage) statusesState = .display(statuses: statuses, nextPageState: .loadingNextPage)
let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: lastId)) let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: lastId))
statuses.append(contentsOf: newStatuses) statuses.append(contentsOf: newStatuses)
state = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} catch { } catch {
state = .error(error: error) statusesState = .error(error: error)
} }
} }
} }