mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-25 09:41:02 +00:00
Begin profile + media preview
This commit is contained in:
parent
eb4dc011b6
commit
70d28e697c
18 changed files with 334 additions and 59 deletions
|
@ -17,6 +17,15 @@
|
|||
"branch" : "master",
|
||||
"revision" : "32a99b537d1c6f3529a08257c28a5feb70c0c5af"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-shimmer",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/markiv/SwiftUI-Shimmer",
|
||||
"state" : {
|
||||
"revision" : "965a7cbcbf094cbcf22b9251a2323bdc3432e171",
|
||||
"version" : "1.1.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
|
|
@ -14,7 +14,7 @@ class AppAccountsManager: ObservableObject {
|
|||
var defaultAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil)
|
||||
do {
|
||||
let keychainAccounts = try AppAccount.retrieveAll()
|
||||
defaultAccount = keychainAccounts.first ?? defaultAccount
|
||||
defaultAccount = keychainAccounts.last ?? defaultAccount
|
||||
} catch {}
|
||||
currentAccount = defaultAccount
|
||||
currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken)
|
||||
|
@ -29,6 +29,7 @@ class AppAccountsManager: ObservableObject {
|
|||
|
||||
func delete(account: AppAccount) {
|
||||
account.delete()
|
||||
AppAccount.deleteAll()
|
||||
currentAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ extension View {
|
|||
switch destination {
|
||||
case let .accountDetail(id):
|
||||
AccountDetailView(accountId: id)
|
||||
case let .accountDetailWithAccount(account):
|
||||
AccountDetailView(account: account)
|
||||
case let .statusDetail(id):
|
||||
StatusDetailView(statusId: id)
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import KeychainSwift
|
|||
|
||||
@main
|
||||
struct IceCubesApp: App {
|
||||
public static let defaultServer = "mastodon.world"
|
||||
public static let defaultServer = "mastodon.social"
|
||||
|
||||
@StateObject private var appAccountsManager = AppAccountsManager()
|
||||
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
import SwiftUI
|
||||
import Models
|
||||
|
||||
struct AccountDetailHeaderView: View {
|
||||
@Environment(\.redactionReasons) private var reasons
|
||||
let account: Account
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
headerImageView
|
||||
accountInfoView
|
||||
}
|
||||
}
|
||||
|
||||
private var headerImageView: some View {
|
||||
AsyncImage(
|
||||
url: account.header,
|
||||
content: { image in
|
||||
image.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxHeight: 200)
|
||||
.clipped()
|
||||
},
|
||||
placeholder: {
|
||||
Color.gray
|
||||
.frame(maxHeight: 20)
|
||||
}
|
||||
)
|
||||
.frame(maxHeight: 200)
|
||||
.background(Color.gray)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
)
|
||||
Spacer()
|
||||
Group {
|
||||
makeCustomInfoLabel(title: "Posts", count: account.statusesCount)
|
||||
makeCustomInfoLabel(title: "Following", count: account.followersCount)
|
||||
makeCustomInfoLabel(title: "Followers", count: account.followersCount)
|
||||
}.offset(y: 20)
|
||||
}
|
||||
}
|
||||
|
||||
private var accountInfoView: some View {
|
||||
Group {
|
||||
accountAvatarView
|
||||
Text(account.displayName)
|
||||
.font(.headline)
|
||||
Text(account.acct)
|
||||
.font(.callout)
|
||||
.foregroundColor(.gray)
|
||||
Text(account.note.asSafeAttributedString)
|
||||
.font(.body)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.offset(y: -40)
|
||||
}
|
||||
|
||||
private func makeCustomInfoLabel(title: String, count: Int) -> some View {
|
||||
VStack {
|
||||
Text(title)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.gray)
|
||||
Text("\(count)")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountDetailHeaderView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AccountDetailHeaderView(account: .placeholder())
|
||||
}
|
||||
}
|
|
@ -10,29 +10,35 @@ public struct AccountDetailView: View {
|
|||
_viewModel = StateObject(wrappedValue: .init(accountId: accountId))
|
||||
}
|
||||
|
||||
public init(account: Account) {
|
||||
_viewModel = StateObject(wrappedValue: .init(account: account))
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
List {
|
||||
switch viewModel.state {
|
||||
case .loading:
|
||||
loadingRow
|
||||
case let .data(account):
|
||||
Text("Account id \(account.id)")
|
||||
Text("Account name \(account.displayName)")
|
||||
case let .error(error):
|
||||
Text("Error: \(error.localizedDescription)")
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
switch viewModel.state {
|
||||
case .loading:
|
||||
AccountDetailHeaderView(account: .placeholder())
|
||||
.redacted(reason: .placeholder)
|
||||
case let .data(account):
|
||||
AccountDetailHeaderView(account: account)
|
||||
case let .error(error):
|
||||
Text("Error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.edgesIgnoringSafeArea(.top)
|
||||
.task {
|
||||
viewModel.client = client
|
||||
await viewModel.fetchAccount()
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingRow: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountDetailView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AccountDetailView(account: .placeholder())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,11 @@ class AccountDetailViewModel: ObservableObject {
|
|||
self.accountId = accountId
|
||||
}
|
||||
|
||||
init(account: Account) {
|
||||
self.accountId = account.id
|
||||
self.state = .data(account: account)
|
||||
}
|
||||
|
||||
func fetchAccount() async {
|
||||
do {
|
||||
state = .data(account: try await client.get(endpoint: Accounts.accounts(id: accountId)))
|
||||
|
|
|
@ -1,9 +1,45 @@
|
|||
import Foundation
|
||||
|
||||
public struct Account: Codable, Identifiable {
|
||||
public struct Account: Codable, Identifiable, Equatable, Hashable {
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
public struct Field: Codable, Equatable {
|
||||
public let name: String
|
||||
public let value: String
|
||||
public let verifiedAt: String?
|
||||
}
|
||||
public let id: String
|
||||
public let username: String
|
||||
public let displayName: String
|
||||
public let avatar: URL
|
||||
public let header: URL
|
||||
public let acct: String
|
||||
public let note: HTMLString
|
||||
public let createdAt: ServerDate
|
||||
public let followersCount: Int
|
||||
public let followingCount: Int
|
||||
public let statusesCount: Int
|
||||
public let lastStatusAt: String?
|
||||
public let fields: [Field]
|
||||
public let locked: Bool
|
||||
|
||||
public static func placeholder() -> Account {
|
||||
.init(id: UUID().uuidString,
|
||||
username: "Username",
|
||||
displayName: "Display Name",
|
||||
avatar: URL(string: "https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png")!,
|
||||
header: URL(string: "https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png")!,
|
||||
acct: "account@account.com",
|
||||
note: "Some content",
|
||||
createdAt: "2022-12-16T10:20:54.000Z",
|
||||
followersCount: 10,
|
||||
followingCount: 10,
|
||||
statusesCount: 10,
|
||||
lastStatusAt: nil,
|
||||
fields: [],
|
||||
locked: false)
|
||||
}
|
||||
}
|
||||
|
|
25
Packages/Models/Sources/Models/Alias/HTMLString.swift
Normal file
25
Packages/Models/Sources/Models/Alias/HTMLString.swift
Normal file
|
@ -0,0 +1,25 @@
|
|||
import Foundation
|
||||
import HTML2Markdown
|
||||
import SwiftUI
|
||||
|
||||
public typealias HTMLString = String
|
||||
|
||||
extension HTMLString {
|
||||
public var asMarkdown: String {
|
||||
do {
|
||||
let dom = try HTMLParser().parse(html: self)
|
||||
return dom.toMarkdown()
|
||||
} catch {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
public var asSafeAttributedString: AttributedString {
|
||||
do {
|
||||
return try AttributedString(markdown: asMarkdown)
|
||||
} catch {
|
||||
return AttributedString(stringLiteral: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import HTML2Markdown
|
||||
import Foundation
|
||||
|
||||
extension AnyStatus {
|
||||
public typealias ServerDate = String
|
||||
|
||||
extension ServerDate {
|
||||
private static var createdAtDateFormatter: DateFormatter {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.calendar = .init(identifier: .iso8601)
|
||||
|
@ -22,29 +23,21 @@ extension AnyStatus {
|
|||
return dateFormatter
|
||||
}
|
||||
|
||||
public var contentAsMarkdown: String {
|
||||
do {
|
||||
let dom = try HTMLParser().parse(html: content)
|
||||
return dom.toMarkdown()
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
public var asDate: Date {
|
||||
Self.createdAtDateFormatter.date(from: self)!
|
||||
}
|
||||
|
||||
public var createdAtDate: Date {
|
||||
Self.createdAtDateFormatter.date(from: createdAt)!
|
||||
}
|
||||
|
||||
public var createdAtFormatted: String {
|
||||
public var formatted: String {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
if calendar.numberOfDaysBetween(createdAtDate, and: Date()) > 1 {
|
||||
return Self.createdAtShortDateFormatted.string(from: createdAtDate)
|
||||
if calendar.numberOfDaysBetween(asDate, and: Date()) > 1 {
|
||||
return Self.createdAtShortDateFormatted.string(from: asDate)
|
||||
} else {
|
||||
return Self.createdAtRelativeFormatter.localizedString(for: createdAtDate, relativeTo: Date())
|
||||
return Self.createdAtRelativeFormatter.localizedString(for: asDate, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension Calendar {
|
||||
func numberOfDaysBetween(_ from: Date, and to: Date) -> Int {
|
||||
let fromDate = startOfDay(for: from)
|
20
Packages/Models/Sources/Models/MediaAttachement.swift
Normal file
20
Packages/Models/Sources/Models/MediaAttachement.swift
Normal file
|
@ -0,0 +1,20 @@
|
|||
import Foundation
|
||||
|
||||
public struct MediaAttachement: Codable, Identifiable {
|
||||
public struct Meta: Codable {
|
||||
public let width: Int?
|
||||
public let height: Int?
|
||||
public let size: String?
|
||||
public let aspect: Float?
|
||||
public let x: Float?
|
||||
public let y: Float?
|
||||
}
|
||||
|
||||
public let id: String
|
||||
public let type: String
|
||||
public let url: URL
|
||||
public let previewUrl: URL
|
||||
public let description: String?
|
||||
public let meta: [String: Meta]
|
||||
}
|
||||
|
|
@ -5,14 +5,29 @@ public protocol AnyStatus {
|
|||
var content: String { get }
|
||||
var account: Account { get }
|
||||
var createdAt: String { get }
|
||||
var mediaAttachments: [MediaAttachement] { get }
|
||||
}
|
||||
|
||||
public struct Status: AnyStatus, Codable, Identifiable {
|
||||
public let id: String
|
||||
public let content: String
|
||||
public let content: HTMLString
|
||||
public let account: Account
|
||||
public let createdAt: String
|
||||
public let createdAt: ServerDate
|
||||
public let reblog: ReblogStatus?
|
||||
public let mediaAttachments: [MediaAttachement]
|
||||
|
||||
public static func placeholder() -> Status {
|
||||
.init(id: UUID().uuidString,
|
||||
content: "Some post content\n Some more post content \n Some more",
|
||||
account: .placeholder(),
|
||||
createdAt: "2022-12-16T10:20:54.000Z",
|
||||
reblog: nil,
|
||||
mediaAttachments: [])
|
||||
}
|
||||
|
||||
public static func placeholders() -> [Status] {
|
||||
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReblogStatus: AnyStatus, Codable, Identifiable {
|
||||
|
@ -20,4 +35,5 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable {
|
|||
public let content: String
|
||||
public let account: Account
|
||||
public let createdAt: String
|
||||
public let mediaAttachments: [MediaAttachement]
|
||||
}
|
||||
|
|
|
@ -13,11 +13,15 @@ let package = Package(
|
|||
name: "Routeur",
|
||||
targets: ["Routeur"]),
|
||||
],
|
||||
dependencies: [],
|
||||
dependencies: [
|
||||
.package(name: "Models", path: "../Models")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Routeur",
|
||||
dependencies: []),
|
||||
dependencies: [
|
||||
.product(name: "Models", package: "Models"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "RouteurTests",
|
||||
dependencies: ["Routeur"]),
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
import Models
|
||||
|
||||
public enum RouteurDestinations: Hashable {
|
||||
case accountDetail(id: String)
|
||||
case accountDetailWithAccount(account: Account)
|
||||
case statusDetail(id: String)
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ let package = Package(
|
|||
.package(name: "Network", path: "../Network"),
|
||||
.package(name: "Models", path: "../Models"),
|
||||
.package(name: "Routeur", path: "../Routeur"),
|
||||
.package(url: "https://github.com/markiv/SwiftUI-Shimmer", exact: "1.1.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
|
@ -24,7 +25,8 @@ let package = Package(
|
|||
dependencies: [
|
||||
.product(name: "Network", package: "Network"),
|
||||
.product(name: "Models", package: "Models"),
|
||||
.product(name: "Routeur", package: "Routeur")
|
||||
.product(name: "Routeur", package: "Routeur"),
|
||||
.product(name: "Shimmer", package: "SwiftUI-Shimmer")
|
||||
]),
|
||||
.testTarget(
|
||||
name: "TimelineTests",
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import SwiftUI
|
||||
import Models
|
||||
|
||||
public struct StatusMediaPreviewView: View {
|
||||
public let attachements: [MediaAttachement]
|
||||
|
||||
public var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
if let firstAttachement = attachements.first {
|
||||
makePreviewImage(attachement: firstAttachement)
|
||||
}
|
||||
if attachements.count > 1, let secondAttachement = attachements[1] {
|
||||
makePreviewImage(attachement: secondAttachement)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
if attachements.count > 2, let secondAttachement = attachements[2] {
|
||||
makePreviewImage(attachement: secondAttachement)
|
||||
}
|
||||
if attachements.count > 3, let secondAttachement = attachements[3] {
|
||||
makePreviewImage(attachement: secondAttachement)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makePreviewImage(attachement: MediaAttachement) -> some View {
|
||||
AsyncImage(
|
||||
url: attachement.url,
|
||||
content: { image in
|
||||
image.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxHeight: 200)
|
||||
.clipped()
|
||||
.cornerRadius(4)
|
||||
},
|
||||
placeholder: {
|
||||
ProgressView()
|
||||
.frame(maxWidth: 80, maxHeight: 80)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ import Models
|
|||
import Routeur
|
||||
|
||||
struct StatusRowView: View {
|
||||
@Environment(\.redactionReasons) private var reasons
|
||||
@EnvironmentObject private var routeurPath: RouterPath
|
||||
|
||||
let status: Status
|
||||
|
@ -33,34 +34,45 @@ struct StatusRowView: View {
|
|||
private var statusView: some View {
|
||||
if let status: AnyStatus = status.reblog ?? status {
|
||||
Button {
|
||||
routeurPath.navigate(to: .accountDetail(id: status.account.id))
|
||||
routeurPath.navigate(to: .accountDetailWithAccount(account: status.account))
|
||||
} label: {
|
||||
makeAccountView(status: status)
|
||||
}.buttonStyle(.plain)
|
||||
|
||||
Text(try! AttributedString(markdown: status.contentAsMarkdown))
|
||||
Text(try! AttributedString(markdown: status.content.asMarkdown))
|
||||
.font(.body)
|
||||
.onTapGesture {
|
||||
routeurPath.navigate(to: .statusDetail(id: status.id))
|
||||
}
|
||||
|
||||
if !status.mediaAttachments.isEmpty {
|
||||
StatusMediaPreviewView(attachements: status.mediaAttachments)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeAccountView(status: AnyStatus) -> some View {
|
||||
AsyncImage(
|
||||
url: status.account.avatar,
|
||||
content: { image in
|
||||
image.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.cornerRadius(4)
|
||||
.frame(maxWidth: 40, maxHeight: 40)
|
||||
},
|
||||
placeholder: {
|
||||
ProgressView()
|
||||
.frame(maxWidth: 40, maxHeight: 40)
|
||||
}
|
||||
)
|
||||
if reasons == .placeholder {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(.gray)
|
||||
.frame(maxWidth: 40, maxHeight: 40)
|
||||
} else {
|
||||
AsyncImage(
|
||||
url: status.account.avatar,
|
||||
content: { image in
|
||||
image.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.cornerRadius(4)
|
||||
.frame(maxWidth: 40, maxHeight: 40)
|
||||
},
|
||||
placeholder: {
|
||||
ProgressView()
|
||||
.frame(maxWidth: 40, maxHeight: 40)
|
||||
}
|
||||
)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text(status.account.displayName)
|
||||
.font(.headline)
|
||||
|
@ -69,7 +81,7 @@ struct StatusRowView: View {
|
|||
.font(.footnote)
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text(status.createdAtFormatted)
|
||||
Text(status.createdAt.formatted)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import SwiftUI
|
||||
import Network
|
||||
import Models
|
||||
import Shimmer
|
||||
|
||||
public struct TimelineView: View {
|
||||
@EnvironmentObject private var client: Client
|
||||
|
@ -12,7 +14,11 @@ public struct TimelineView: View {
|
|||
List {
|
||||
switch viewModel.state {
|
||||
case .loading:
|
||||
loadingRow
|
||||
ForEach(Status.placeholders()) { placeholder in
|
||||
StatusRowView(status: placeholder)
|
||||
.redacted(reason: .placeholder)
|
||||
.shimmering()
|
||||
}
|
||||
case let .error(error):
|
||||
Text(error.localizedDescription)
|
||||
case let .display(statuses, nextPageState):
|
||||
|
@ -33,7 +39,7 @@ public struct TimelineView: View {
|
|||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("\(viewModel.serverName)")
|
||||
.navigationTitle(viewModel.timeline.rawValue)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
|
|
Loading…
Reference in a new issue