Explore + Account polish + Status editor WIP

This commit is contained in:
Thomas Ricouard 2022-12-23 10:41:55 +01:00
parent 0679559ced
commit 189037b53d
21 changed files with 358 additions and 59 deletions

View File

@ -17,6 +17,8 @@
9F398AA92935FFDB00A889F2 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AA82935FFDB00A889F2 /* Account */; };
9F398AAB2935FFDB00A889F2 /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AAA2935FFDB00A889F2 /* Models */; };
9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AB229360A4C00A889F2 /* TimelineTab.swift */; };
9F55C68D2955968700F94077 /* ExploreTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F55C68C2955968700F94077 /* ExploreTab.swift */; };
9F55C6902955993C00F94077 /* Explore in Frameworks */ = {isa = PBXBuildFile; productRef = 9F55C68F2955993C00F94077 /* Explore */; };
9F5E581929545BE700A53960 /* Env in Frameworks */ = {isa = PBXBuildFile; productRef = 9F5E581829545BE700A53960 /* Env */; };
9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4ACA293783B000772766 /* SettingsTab.swift */; };
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9FAE4ACD29379A5A00772766 /* KeychainSwift */; };
@ -41,6 +43,8 @@
9F398AA52935FE8A00A889F2 /* AppRouteur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteur.swift; sourceTree = "<group>"; };
9F398AAC2936005300A889F2 /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Account; path = Packages/Account; sourceTree = "<group>"; };
9F398AB229360A4C00A889F2 /* TimelineTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTab.swift; sourceTree = "<group>"; };
9F55C68C2955968700F94077 /* ExploreTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreTab.swift; sourceTree = "<group>"; };
9F55C68E295598F900F94077 /* Explore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Explore; path = Packages/Explore; sourceTree = "<group>"; };
9F5E581729545B5500A53960 /* Env */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Env; path = Packages/Env; sourceTree = "<group>"; };
9FAE4AC8293774FF00772766 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
9FAE4ACA293783B000772766 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = "<group>"; };
@ -58,6 +62,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9F55C6902955993C00F94077 /* Explore in Frameworks */,
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */,
9F398AA92935FFDB00A889F2 /* Account in Frameworks */,
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */,
@ -98,6 +103,7 @@
9F398AB229360A4C00A889F2 /* TimelineTab.swift */,
9F35DB4629506F6600B3281A /* NotificationTab.swift */,
9F35DB4B2952005C00B3281A /* AccountTab.swift */,
9F55C68C2955968700F94077 /* ExploreTab.swift */,
);
path = Tabs;
sourceTree = "<group>";
@ -119,6 +125,7 @@
9FBFE64C292A72BD00C250E9 /* Frameworks */,
9F398AAC2936005300A889F2 /* Account */,
9F35DB45294FA04C00B3281A /* DesignSystem */,
9F55C68E295598F900F94077 /* Explore */,
9F5E581729545B5500A53960 /* Env */,
9F398AA32935F90100A889F2 /* Models */,
9F29553D292B67B600E0E81B /* Network */,
@ -189,6 +196,7 @@
9F35DB43294F9A7D00B3281A /* Status */,
9F35DB4929506FA100B3281A /* Notifications */,
9F5E581829545BE700A53960 /* Env */,
9F55C68F2955993C00F94077 /* Explore */,
);
productName = IceCubesApp;
productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */;
@ -256,6 +264,7 @@
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */,
9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */,
9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */,
9F55C68D2955968700F94077 /* ExploreTab.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -516,6 +525,10 @@
isa = XCSwiftPackageProductDependency;
productName = Models;
};
9F55C68F2955993C00F94077 /* Explore */ = {
isa = XCSwiftPackageProductDependency;
productName = Explore;
};
9F5E581829545BE700A53960 /* Env */ = {
isa = XCSwiftPackageProductDependency;
productName = Env;

View File

@ -24,8 +24,8 @@ extension View {
func withSheetDestinations(sheetDestinations: Binding<SheetDestinations?>) -> some View {
self.sheet(item: sheetDestinations) { destination in
switch destination {
default:
EmptyView()
case .statusEditor:
StatusEditorView()
}
}
}

View File

@ -17,13 +17,19 @@ struct IceCubesApp: App {
TabView {
TimelineTab()
.tabItem {
Label("Home", systemImage: "globe")
Label("Timeline", systemImage: "rectangle.on.rectangle")
}
if appAccountsManager.currentClient.isAuth {
NotificationsTab()
.tabItem {
Label("Notifications", systemImage: "bell")
}
}
ExploreTab()
.tabItem {
Label("Explore", systemImage: "magnifyingglass")
}
if appAccountsManager.currentClient.isAuth {
AccountTab()
.tabItem {
Label("Profile", systemImage: "person.circle")

View File

@ -0,0 +1,18 @@
import SwiftUI
import Env
import Models
import Shimmer
import Explore
struct ExploreTab: View {
@StateObject private var routeurPath = RouterPath()
var body: some View {
NavigationStack(path: $routeurPath.path) {
ExploreView()
.withAppRouteur()
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
}
.environmentObject(routeurPath)
}
}

View File

@ -11,6 +11,16 @@ struct TimelineTab: View {
TimelineView()
.withAppRouteur()
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
routeurPath.presentedSheet = .statusEditor(replyToStatus: nil)
} label: {
Image(systemName: "square.and.pencil")
}
}
}
}
.environmentObject(routeurPath)
}

View File

@ -66,24 +66,11 @@ struct AccountDetailHeaderView: View {
private var accountAvatarView: some View {
HStack {
AsyncImage(
url: account.avatar,
content: { image in
image.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(4)
.frame(maxWidth: 80, maxHeight: 80)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(.white, lineWidth: 1)
)
},
placeholder: {
ProgressView()
.frame(maxWidth: 80, maxHeight: 80)
}
)
.contentShape(Rectangle())
AvatarView(url: account.avatar, size: .account)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(.white, lineWidth: 1)
)
.onTapGesture {
Task {
await quickLook.prepareFor(urls: [account.avatar], selectedURL: account.avatar)

View File

@ -77,7 +77,7 @@ public struct AccountDetailView: View {
}
}
.edgesIgnoringSafeArea(.top)
.navigationTitle(Text(scrollOffset < -20 ? viewModel.title : ""))
.navigationTitle(Text(scrollOffset < -200 ? viewModel.title : ""))
}
@ViewBuilder
@ -205,21 +205,9 @@ public struct AccountDetailView: View {
private func makeTagsListView(tags: [Tag]) -> some View {
Group {
ForEach(tags) { tag in
HStack {
VStack(alignment: .leading) {
Text("#\(tag.name)")
.font(.headline)
Text("\(tag.totalUses) posts from \(tag.totalAccounts) participants")
.font(.footnote)
.foregroundColor(.gray)
}
Spacer()
}
.padding(.horizontal, DS.Constants.layoutPadding)
.padding(.vertical, 8)
.onTapGesture {
routeurPath.navigate(to: .hashTag(tag: tag.name, account: nil))
}
TagRowView(tag: tag)
.padding(.horizontal, DS.Constants.layoutPadding)
.padding(.vertical, 8)
}
}
}

View File

@ -3,30 +3,41 @@ import Shimmer
public struct AvatarView: View {
public enum Size {
case profile, badge
case account, status, badge
var size: CGSize {
switch self {
case .profile:
case .account:
return .init(width: 80, height: 80)
case .status:
return .init(width: 40, height: 40)
case .badge:
return .init(width: 28, height: 28)
}
}
var cornerRadius: CGFloat {
switch self {
case .badge:
return size.width / 2
default:
return 4
}
}
}
@Environment(\.redactionReasons) private var reasons
public let url: URL
public let size: Size
public init(url: URL, size: Size = .profile) {
public init(url: URL, size: Size = .status) {
self.url = url
self.size = size
}
public var body: some View {
if reasons == .placeholder {
RoundedRectangle(cornerRadius: size == .profile ? 4 : size.size.width / 2)
RoundedRectangle(cornerRadius: size.cornerRadius)
.fill(.gray)
.frame(maxWidth: size.size.width, maxHeight: size.size.height)
} else {
@ -39,7 +50,7 @@ public struct AvatarView: View {
.frame(width: size.size.width, height: size.size.height)
.shimmering()
} else {
RoundedRectangle(cornerRadius: size == .profile ? 4 : size.size.width / 2)
RoundedRectangle(cornerRadius: size.cornerRadius)
.fill(.gray)
.frame(width: size.size.width, height: size.size.height)
.shimmering()
@ -47,7 +58,7 @@ public struct AvatarView: View {
case let .success(image):
image.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(size == .profile ? 4 : size.size.width / 2)
.cornerRadius(size.cornerRadius)
.frame(maxWidth: size.size.width, maxHeight: size.size.height)
case .failure:
EmptyView()

View File

@ -0,0 +1,29 @@
import Models
import SwiftUI
import Env
public struct TagRowView: View {
@EnvironmentObject private var routeurPath: RouterPath
let tag: Tag
public init(tag: Tag) {
self.tag = tag
}
public var body: some View {
HStack {
VStack(alignment: .leading) {
Text("#\(tag.name)")
.font(.headline)
Text("\(tag.totalUses) posts from \(tag.totalAccounts) participants")
.font(.footnote)
.foregroundColor(.gray)
}
Spacer()
}
.onTapGesture {
routeurPath.navigate(to: .hashTag(tag: tag.name, account: nil))
}
}
}

View File

@ -10,10 +10,12 @@ public enum RouteurDestinations: Hashable {
}
public enum SheetDestinations: Identifiable {
case statusEditor(replyToStatus: String?)
public var id: String {
switch self {
default:
break
case .statusEditor:
return "statusEditor"
}
}
}

9
Packages/Explore/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,35 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Explore",
platforms: [
.iOS(.v16),
],
products: [
.library(
name: "Explore",
targets: ["Explore"]),
],
dependencies: [
.package(name: "Network", path: "../Network"),
.package(name: "Models", path: "../Models"),
.package(name: "Env", path: "../Env"),
.package(name: "Status", path: "../Status"),
.package(url: "https://github.com/markiv/SwiftUI-Shimmer", exact: "1.1.0")
],
targets: [
.target(
name: "Explore",
dependencies: [
.product(name: "Network", package: "Network"),
.product(name: "Models", package: "Models"),
.product(name: "Env", package: "Env"),
.product(name: "Status", package: "Status"),
.product(name: "Shimmer", package: "SwiftUI-Shimmer")
])
]
)

View File

@ -0,0 +1,3 @@
# Explore
A description of this package.

View File

@ -0,0 +1,95 @@
import SwiftUI
import Env
import Network
import DesignSystem
import Models
import Status
public struct ExploreView: View {
@EnvironmentObject private var client: Client
@EnvironmentObject private var routeurPath: RouterPath
@StateObject private var viewModel = ExploreViewModel()
@State private var searchQuery: String = ""
public init() { }
public var body: some View {
List {
Section("Trending Tags") {
ForEach(viewModel.trendingTags
.prefix(upTo: viewModel.trendingTags.count > 5 ? 5 : viewModel.trendingTags.count)) { tag in
TagRowView(tag: tag)
.padding(.vertical, 4)
}
NavigationLink {
List {
ForEach(viewModel.trendingTags) { tag in
TagRowView(tag: tag)
.padding(.vertical, 4)
}
}
.listStyle(.plain)
.navigationTitle("Trending Tags")
.navigationBarTitleDisplayMode(.inline)
} label: {
Text("See more")
.foregroundColor(.brand)
}
}
Section("Trending Posts") {
ForEach(viewModel.trendingStatuses
.prefix(upTo: viewModel.trendingStatuses.count > 3 ? 3 : viewModel.trendingStatuses.count)) { status in
StatusRowView(viewModel: .init(status: status, isEmbed: false))
.padding(.vertical, 8)
}
NavigationLink {
List {
ForEach(viewModel.trendingStatuses) { status in
StatusRowView(viewModel: .init(status: status, isEmbed: false))
.padding(.vertical, 8)
}
}
.listStyle(.plain)
.navigationTitle("Trending Posts")
.navigationBarTitleDisplayMode(.inline)
} label: {
Text("See more")
.foregroundColor(.brand)
}
}
Section("Trending Links") {
ForEach(viewModel.trendingLinks
.prefix(upTo: viewModel.trendingLinks.count > 3 ? 3 : viewModel.trendingLinks.count)) { card in
StatusCardView(card: card)
.padding(.vertical, 8)
}
NavigationLink {
List {
ForEach(viewModel.trendingLinks) { card in
StatusCardView(card: card)
.padding(.vertical, 8)
}
}
.listStyle(.plain)
.navigationTitle("Trending Links")
.navigationBarTitleDisplayMode(.inline)
} label: {
Text("See more")
.foregroundColor(.brand)
}
}
}
.task {
viewModel.client = client
await viewModel.fetchTrending()
}
.listStyle(.grouped)
.navigationTitle("Explore")
.searchable(text: $searchQuery)
}
}

View File

@ -0,0 +1,25 @@
import SwiftUI
import Models
import Network
@MainActor
class ExploreViewModel: ObservableObject {
var client: Client?
@Published var trendingTags: [Tag] = []
@Published var trendingStatuses: [Status] = []
@Published var trendingLinks: [Card] = []
func fetchTrending() async {
guard let client else { return }
do {
async let trendingTags: [Tag] = client.get(endpoint: Trends.tags)
async let trendingStatuses: [Status] = client.get(endpoint: Trends.statuses)
async let trendingLinks: [Card] = client.get(endpoint: Trends.links)
self.trendingTags = try await trendingTags
self.trendingStatuses = try await trendingStatuses
self.trendingLinks = try await trendingLinks
} catch { }
}
}

View File

@ -1,6 +1,10 @@
import Foundation
public struct Card: Codable {
public struct Card: Codable, Identifiable {
public var id: String {
url.absoluteString
}
public let url: URL
public let title: String?
public let description: String?

View File

@ -0,0 +1,25 @@
import Foundation
public enum Trends: Endpoint {
case tags
case statuses
case links
public func path() -> String {
switch self {
case .tags:
return "trends/tags"
case .statuses:
return "trends/statuses"
case .links:
return "trends/links"
}
}
public func queryItems() -> [URLQueryItem]? {
switch self {
default:
return nil
}
}
}

View File

@ -0,0 +1,36 @@
import SwiftUI
public struct StatusEditorView: View {
@Environment(\.dismiss) private var dismiss
@State private var statusText: String = ""
public init() {
}
public var body: some View {
NavigationStack {
Form {
TextEditor(text: $statusText)
}
.navigationTitle("Post a toot")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
dismiss()
} label: {
Text("Post")
}
}
ToolbarItem(placement: .navigationBarLeading) {
Button {
dismiss()
} label: {
Text("Cancel")
}
}
}
}
}
}

View File

@ -4,6 +4,7 @@ import Env
import Network
struct StatusActionsView: View {
@EnvironmentObject private var routeurPath: RouterPath
@ObservedObject var viewModel: StatusRowViewModel
@MainActor
@ -62,6 +63,8 @@ struct StatusActionsView: View {
private func handleAction(action: Actions) {
Task {
switch action {
case .respond:
routeurPath.navigate(to: .statusDetail(id: viewModel.status.reblog?.id ?? viewModel.status.id))
case .favourite:
if viewModel.isFavourited {
await viewModel.unFavourite()

View File

@ -2,12 +2,16 @@ import SwiftUI
import Models
import Shimmer
struct StatusCardView: View {
public struct StatusCardView: View {
@Environment(\.openURL) private var openURL
let status: AnyStatus
let card: Card
var body: some View {
if let card = status.card, let title = card.title {
public init(card: Card) {
self.card = card
}
public var body: some View {
if let title = card.title {
VStack(alignment: .leading) {
if let imageURL = card.image {
AsyncImage(
@ -59,9 +63,3 @@ struct StatusCardView: View {
}
}
}
struct StatusCardView_Previews: PreviewProvider {
static var previews: some View {
StatusCardView(status: Status.placeholder())
}
}

View File

@ -67,14 +67,16 @@ public struct StatusRowView: View {
StatusMediaPreviewView(attachements: status.mediaAttachments)
.padding(.vertical, 4)
}
StatusCardView(status: status)
if let card = status.card {
StatusCardView(card: card)
}
}
}
}
@ViewBuilder
private func makeAccountView(status: AnyStatus) -> some View {
AvatarView(url: status.account.avatar)
AvatarView(url: status.account.avatar, size: .status)
VStack(alignment: .leading, spacing: 0) {
status.account.displayNameWithEmojis
.font(.subheadline)