From e4e2b2ab8bfac35acfbd5f884a8208cbd4fb54a8 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Fri, 23 Dec 2022 18:47:19 +0100 Subject: [PATCH] Following / Followers page 1 --- IceCubesApp/App/AppRouteur.swift | 4 + .../Account/AccountDetailHeaderView.swift | 9 +- .../AccountsLIst/AccountsListRow.swift} | 15 ++-- .../AccountsLIst/AccountsListView.swift | 70 ++++++++++++++++ .../AccountsLIst/AccountsListViewModel.swift | 82 +++++++++++++++++++ .../DesignSystem/Views/AvatarView.swift | 30 ++++--- Packages/Env/Sources/Env/Routeur.swift | 2 + .../Explore/Sources/Explore/ExploreView.swift | 5 +- Packages/Models/Sources/Models/Account.swift | 5 ++ .../Sources/Network/Endpoint/Accounts.swift | 12 +++ 10 files changed, 211 insertions(+), 23 deletions(-) rename Packages/{Explore/Sources/Explore/SuggestedAccountRow.swift => Account/Sources/Account/AccountsLIst/AccountsListRow.swift} (77%) create mode 100644 Packages/Account/Sources/Account/AccountsLIst/AccountsListView.swift create mode 100644 Packages/Account/Sources/Account/AccountsLIst/AccountsListViewModel.swift diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index 857efa8a..c6d50479 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -17,6 +17,10 @@ extension View { StatusDetailView(statusId: id) case let .hashTag(tag, accountId): TimelineView(timeline: .hashtag(tag: tag, accountId: accountId)) + case let .following(id): + AccountsListView(accountId: id, mode: .following) + case let .followers(id): + AccountsListView(accountId: id, mode: .followers) } } } diff --git a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift index 6e41ca55..da023000 100644 --- a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift +++ b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift @@ -79,8 +79,12 @@ struct AccountDetailHeaderView: View { Spacer() Group { makeCustomInfoLabel(title: "Posts", count: account.statusesCount) - makeCustomInfoLabel(title: "Following", count: account.followingCount) - makeCustomInfoLabel(title: "Followers", count: account.followersCount) + NavigationLink(value: RouteurDestinations.following(id: account.id)) { + makeCustomInfoLabel(title: "Following", count: account.followingCount) + } + NavigationLink(value: RouteurDestinations.followers(id: account.id)) { + makeCustomInfoLabel(title: "Followers", count: account.followersCount) + } }.offset(y: 20) } } @@ -117,6 +121,7 @@ struct AccountDetailHeaderView: View { VStack { Text("\(count)") .font(.headline) + .foregroundColor(.brand) Text(title) .font(.footnote) .foregroundColor(.gray) diff --git a/Packages/Explore/Sources/Explore/SuggestedAccountRow.swift b/Packages/Account/Sources/Account/AccountsLIst/AccountsListRow.swift similarity index 77% rename from Packages/Explore/Sources/Explore/SuggestedAccountRow.swift rename to Packages/Account/Sources/Account/AccountsLIst/AccountsListRow.swift index 152d081b..aea1d771 100644 --- a/Packages/Explore/Sources/Explore/SuggestedAccountRow.swift +++ b/Packages/Account/Sources/Account/AccountsLIst/AccountsListRow.swift @@ -3,28 +3,31 @@ import Models import Network import DesignSystem import Env -import Account @MainActor -class SuggestedAccountViewModel: ObservableObject { +public class AccountsListRowViewModel: ObservableObject { var client: Client? @Published var account: Account @Published var relationShip: Relationshionship - init(account: Account, relationShip: Relationshionship) { + public init(account: Account, relationShip: Relationshionship) { self.account = account self.relationShip = relationShip } } -struct SuggestedAccountRow: View { +public struct AccountsListRow: View { @EnvironmentObject private var routeurPath: RouterPath @EnvironmentObject private var client: Client - @StateObject var viewModel: SuggestedAccountViewModel + @StateObject var viewModel: AccountsListRowViewModel - var body: some View { + public init(viewModel: AccountsListRowViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + public var body: some View { HStack(alignment: .top) { AvatarView(url: viewModel.account.avatar, size: .status) VStack(alignment: .leading, spacing: 2) { diff --git a/Packages/Account/Sources/Account/AccountsLIst/AccountsListView.swift b/Packages/Account/Sources/Account/AccountsLIst/AccountsListView.swift new file mode 100644 index 00000000..f3a65442 --- /dev/null +++ b/Packages/Account/Sources/Account/AccountsLIst/AccountsListView.swift @@ -0,0 +1,70 @@ +import SwiftUI +import Network +import Models +import Env +import Shimmer + +public struct AccountsListView: View { + @EnvironmentObject private var client: Client + @StateObject private var viewModel: AccountsListViewModel + @State private var didAppear: Bool = false + + public init(accountId: String, mode: AccountsListMode) { + _viewModel = StateObject(wrappedValue: .init(accountId: accountId, mode: mode)) + } + + public var body: some View { + List { + switch viewModel.state { + case .loading: + ForEach(Account.placeholders()) { account in + AccountsListRow(viewModel: .init(account: .placeholder(), relationShip: .placeholder())) + .redacted(reason: .placeholder) + .shimmering() + } + case let .display(accounts, relationships, nextPageState): + ForEach(accounts) { account in + if let relationship = relationships.first(where: { $0.id == account.id }) { + AccountsListRow(viewModel: .init(account: account, + relationShip: relationship)) + } + } + + switch nextPageState { + case .hasNextPage: + loadingRow + .onAppear { + Task { + await viewModel.fetchNextPage() + } + } + + case .loadingNextPage: + loadingRow + case .none: + EmptyView() + } + + case let .error(error): + Text(error.localizedDescription) + } + } + .listStyle(.plain) + .navigationTitle(viewModel.mode.rawValue.capitalized) + .navigationBarTitleDisplayMode(.inline) + .task { + viewModel.client = client + guard !didAppear else { return} + didAppear = true + await viewModel.fetch() + } + } + + private var loadingRow: some View { + HStack { + Spacer() + ProgressView() + Spacer() + } + } +} diff --git a/Packages/Account/Sources/Account/AccountsLIst/AccountsListViewModel.swift b/Packages/Account/Sources/Account/AccountsLIst/AccountsListViewModel.swift new file mode 100644 index 00000000..97a936a8 --- /dev/null +++ b/Packages/Account/Sources/Account/AccountsLIst/AccountsListViewModel.swift @@ -0,0 +1,82 @@ +import SwiftUI +import Models +import Network + +public enum AccountsListMode: String { + case following, followers +} + +@MainActor +class AccountsListViewModel: ObservableObject { + var client: Client? + + let accountId: String + let mode: AccountsListMode + + public enum State { + public enum PagingState { + case hasNextPage, loadingNextPage, none + } + case loading + case display(accounts: [Account], + relationships: [Relationshionship], + nextPageState: PagingState) + case error(error: Error) + } + + private var accounts: [Account] = [] + private var relationships: [Relationshionship] = [] + + @Published var state = State.loading + + init(accountId: String, mode: AccountsListMode) { + self.accountId = accountId + self.mode = mode + } + + func fetch() async { + guard let client else { return } + do { + state = .loading + switch mode { + case .followers: + accounts = try await client.get(endpoint: Accounts.followers(id: accountId, + sinceId: nil)) + case .following: + accounts = try await client.get(endpoint: Accounts.following(id: accountId, + sinceId: nil)) + } + relationships = try await client.get(endpoint: + Accounts.relationships(ids: accounts.map{ $0.id })) + state = .display(accounts: accounts, + relationships: relationships, + nextPageState: .hasNextPage) + } catch { } + } + + func fetchNextPage() async { + guard let client else { return } + do { + state = .display(accounts: accounts, relationships: relationships, nextPageState: .loadingNextPage) + let newAccounts: [Account] + switch mode { + case .followers: + newAccounts = try await client.get(endpoint: Accounts.followers(id: accountId, + sinceId: accounts.last?.id)) + case .following: + newAccounts = try await client.get(endpoint: Accounts.following(id: accountId, + sinceId: accounts.last?.id)) + } + accounts.append(contentsOf: newAccounts) + let newRelationships: [Relationshionship] = + try await client.get(endpoint: Accounts.relationships(ids: newAccounts.map{ $0.id })) + + relationships.append(contentsOf: newRelationships) + state = .display(accounts: accounts, + relationships: relationships, + nextPageState: .hasNextPage) + } catch { + print(error) + } + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift index ad41f5de..b7b26f62 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift @@ -44,28 +44,32 @@ public struct AvatarView: View { AsyncImage(url: url) { phase in switch phase { case .empty: - if size == .badge { - Circle() - .fill(.gray) - .frame(width: size.size.width, height: size.size.height) - .shimmering() - } else { - RoundedRectangle(cornerRadius: size.cornerRadius) - .fill(.gray) - .frame(width: size.size.width, height: size.size.height) - .shimmering() - } + placeholderView + .shimmering() case let .success(image): image.resizable() .aspectRatio(contentMode: .fit) .cornerRadius(size.cornerRadius) .frame(maxWidth: size.size.width, maxHeight: size.size.height) case .failure: - EmptyView() + placeholderView @unknown default: - EmptyView() + placeholderView } } } } + + @ViewBuilder + private var placeholderView: some View { + if size == .badge { + Circle() + .fill(.gray) + .frame(width: size.size.width, height: size.size.height) + } else { + RoundedRectangle(cornerRadius: size.cornerRadius) + .fill(.gray) + .frame(width: size.size.width, height: size.size.height) + } + } } diff --git a/Packages/Env/Sources/Env/Routeur.swift b/Packages/Env/Sources/Env/Routeur.swift index d7157fa8..8edd94a0 100644 --- a/Packages/Env/Sources/Env/Routeur.swift +++ b/Packages/Env/Sources/Env/Routeur.swift @@ -7,6 +7,8 @@ public enum RouteurDestinations: Hashable { case accountDetailWithAccount(account: Account) case statusDetail(id: String) case hashTag(tag: String, account: String?) + case followers(id: String) + case following(id: String) } public enum SheetDestinations: Identifiable { diff --git a/Packages/Explore/Sources/Explore/ExploreView.swift b/Packages/Explore/Sources/Explore/ExploreView.swift index ee311d64..5b3a7393 100644 --- a/Packages/Explore/Sources/Explore/ExploreView.swift +++ b/Packages/Explore/Sources/Explore/ExploreView.swift @@ -5,6 +5,7 @@ import DesignSystem import Models import Status import Shimmer +import Account public struct ExploreView: View { @EnvironmentObject private var client: Client @@ -51,14 +52,14 @@ public struct ExploreView: View { ForEach(viewModel.suggestedAccounts .prefix(upTo: viewModel.suggestedAccounts.count > 3 ? 3 : viewModel.suggestedAccounts.count)) { account in if let relationship = viewModel.suggestedAccountsRelationShips.first(where: { $0.id == account.id }) { - SuggestedAccountRow(viewModel: .init(account: account, relationShip: relationship)) + AccountsListRow(viewModel: .init(account: account, relationShip: relationship)) } } NavigationLink { List { ForEach(viewModel.suggestedAccounts) { account in if let relationship = viewModel.suggestedAccountsRelationShips.first(where: { $0.id == account.id }) { - SuggestedAccountRow(viewModel: .init(account: account, relationShip: relationship)) + AccountsListRow(viewModel: .init(account: account, relationShip: relationship)) } } } diff --git a/Packages/Models/Sources/Models/Account.swift b/Packages/Models/Sources/Models/Account.swift index 85446dec..d73a861d 100644 --- a/Packages/Models/Sources/Models/Account.swift +++ b/Packages/Models/Sources/Models/Account.swift @@ -48,6 +48,11 @@ public struct Account: Codable, Identifiable, Equatable, Hashable { locked: false, emojis: []) } + + public static func placeholders() -> [Account] { + [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), + .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] + } } public struct FamilliarAccounts: Codable { diff --git a/Packages/Network/Sources/Network/Endpoint/Accounts.swift b/Packages/Network/Sources/Network/Endpoint/Accounts.swift index 6d960743..a4c3af1f 100644 --- a/Packages/Network/Sources/Network/Endpoint/Accounts.swift +++ b/Packages/Network/Sources/Network/Endpoint/Accounts.swift @@ -12,6 +12,8 @@ public enum Accounts: Endpoint { case unfollow(id: String) case familiarFollowers(withAccount: String) case suggestions + case followers(id: String, sinceId: String?) + case following(id: String, sinceId: String?) public func path() -> String { switch self { @@ -37,6 +39,10 @@ public enum Accounts: Endpoint { return "accounts/familiar_followers" case .suggestions: return "suggestions" + case .following(let id, _): + return "accounts/\(id)/following" + case .followers(let id, _): + return "accounts/\(id)/followers" } } @@ -57,6 +63,12 @@ public enum Accounts: Endpoint { } case let .familiarFollowers(withAccount): return [.init(name: "id[]", value: withAccount)] + case let .followers(_, sinceId): + guard let sinceId else { return nil } + return [.init(name: "max_id", value: sinceId)] + case let .following(_, sinceId): + guard let sinceId else { return nil } + return [.init(name: "max_id", value: sinceId)] default: return nil }