Begin profile + media preview

This commit is contained in:
Thomas Ricouard 2022-12-17 13:37:46 +01:00
parent eb4dc011b6
commit 70d28e697c
18 changed files with 334 additions and 59 deletions

View file

@ -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

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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())
}
}

View file

@ -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())
}
}

View file

@ -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)))

View file

@ -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)
}
}

View 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)
}
}
}

View file

@ -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)

View 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]
}

View file

@ -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]
}

View file

@ -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"]),

View file

@ -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)
}

View file

@ -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",

View file

@ -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)
}
)
}
}

View file

@ -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)
}

View file

@ -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) {