Add account switcher feature.

This commit is contained in:
Marcin Czachursk 2023-01-11 13:16:43 +01:00
parent 9e21faed2f
commit ea2eb0a972
20 changed files with 339 additions and 183 deletions

View File

@ -79,6 +79,8 @@
F89992C9296D6DC7005994BF /* CommentBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89992C8296D6DC7005994BF /* CommentBody.swift */; }; F89992C9296D6DC7005994BF /* CommentBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89992C8296D6DC7005994BF /* CommentBody.swift */; };
F89992CC296D9231005994BF /* StatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89992CB296D9231005994BF /* StatusViewModel.swift */; }; F89992CC296D9231005994BF /* StatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89992CB296D9231005994BF /* StatusViewModel.swift */; };
F89992CE296D92E7005994BF /* AttachmentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89992CD296D92E7005994BF /* AttachmentViewModel.swift */; }; F89992CE296D92E7005994BF /* AttachmentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89992CD296D92E7005994BF /* AttachmentViewModel.swift */; };
F89A46DC296EAACE0062125F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89A46DB296EAACE0062125F /* SettingsView.swift */; };
F89A46DE296EABA20062125F /* StatusPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89A46DD296EABA20062125F /* StatusPlaceholder.swift */; };
F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7D2965FD89001D8331 /* UserProfileView.swift */; }; F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7D2965FD89001D8331 /* UserProfileView.swift */; };
F8A93D802965FED4001D8331 /* AccountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7F2965FED4001D8331 /* AccountService.swift */; }; F8A93D802965FED4001D8331 /* AccountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7F2965FED4001D8331 /* AccountService.swift */; };
F8C14392296AF0B3001FE31D /* String+Exif.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C14391296AF0B3001FE31D /* String+Exif.swift */; }; F8C14392296AF0B3001FE31D /* String+Exif.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C14391296AF0B3001FE31D /* String+Exif.swift */; };
@ -158,6 +160,8 @@
F89992C8296D6DC7005994BF /* CommentBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentBody.swift; sourceTree = "<group>"; }; F89992C8296D6DC7005994BF /* CommentBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentBody.swift; sourceTree = "<group>"; };
F89992CB296D9231005994BF /* StatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusViewModel.swift; sourceTree = "<group>"; }; F89992CB296D9231005994BF /* StatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusViewModel.swift; sourceTree = "<group>"; };
F89992CD296D92E7005994BF /* AttachmentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentViewModel.swift; sourceTree = "<group>"; }; F89992CD296D92E7005994BF /* AttachmentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentViewModel.swift; sourceTree = "<group>"; };
F89A46DB296EAACE0062125F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
F89A46DD296EABA20062125F /* StatusPlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPlaceholder.swift; sourceTree = "<group>"; };
F8A93D7D2965FD89001D8331 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; }; F8A93D7D2965FD89001D8331 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
F8A93D7F2965FED4001D8331 /* AccountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountService.swift; sourceTree = "<group>"; }; F8A93D7F2965FED4001D8331 /* AccountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountService.swift; sourceTree = "<group>"; };
F8C14391296AF0B3001FE31D /* String+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Exif.swift"; sourceTree = "<group>"; }; F8C14391296AF0B3001FE31D /* String+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Exif.swift"; sourceTree = "<group>"; };
@ -202,6 +206,7 @@
F85DBF902967385F0069BF89 /* FollowingView.swift */, F85DBF902967385F0069BF89 /* FollowingView.swift */,
F897978E29684BCB00B22335 /* LoadingView.swift */, F897978E29684BCB00B22335 /* LoadingView.swift */,
F88ABD9329687CA4004EF61E /* ComposeView.swift */, F88ABD9329687CA4004EF61E /* ComposeView.swift */,
F89A46DB296EAACE0062125F /* SettingsView.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -280,6 +285,7 @@
F86B721D296C458700EE59EC /* BlurredImage.swift */, F86B721D296C458700EE59EC /* BlurredImage.swift */,
F86B7222296C4BF500EE59EC /* ContentWarning.swift */, F86B7222296C4BF500EE59EC /* ContentWarning.swift */,
F89992C8296D6DC7005994BF /* CommentBody.swift */, F89992C8296D6DC7005994BF /* CommentBody.swift */,
F89A46DD296EABA20062125F /* StatusPlaceholder.swift */,
); );
path = Widgets; path = Widgets;
sourceTree = "<group>"; sourceTree = "<group>";
@ -509,6 +515,7 @@
F88C246E295C37B80006098B /* MainView.swift in Sources */, F88C246E295C37B80006098B /* MainView.swift in Sources */,
F86B721E296C458700EE59EC /* BlurredImage.swift in Sources */, F86B721E296C458700EE59EC /* BlurredImage.swift in Sources */,
F88C2478295C37BB0006098B /* Vernissage.xcdatamodeld in Sources */, F88C2478295C37BB0006098B /* Vernissage.xcdatamodeld in Sources */,
F89A46DE296EABA20062125F /* StatusPlaceholder.swift in Sources */,
F88C2482295C3A4F0006098B /* StatusView.swift in Sources */, F88C2482295C3A4F0006098B /* StatusView.swift in Sources */,
F866F6A329604161002E8F88 /* AccountDataHandler.swift in Sources */, F866F6A329604161002E8F88 /* AccountDataHandler.swift in Sources */,
F85D497F296416C800751DF7 /* CommentsSection.swift in Sources */, F85D497F296416C800751DF7 /* CommentsSection.swift in Sources */,
@ -533,6 +540,7 @@
F88FAD2D295F4AD7009B20C9 /* ApplicationState.swift in Sources */, F88FAD2D295F4AD7009B20C9 /* ApplicationState.swift in Sources */,
F866F6A1296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift in Sources */, F866F6A1296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift in Sources */,
F8C14394296AF21B001FE31D /* Double+Round.swift in Sources */, F8C14394296AF21B001FE31D /* Double+Round.swift in Sources */,
F89A46DC296EAACE0062125F /* SettingsView.swift in Sources */,
F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */, F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */,
F8A93D802965FED4001D8331 /* AccountService.swift in Sources */, F8A93D802965FED4001D8331 /* AccountService.swift in Sources */,
F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */, F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */,

View File

@ -1,6 +1,6 @@
// //
// https://mczachurski.dev // https://mczachurski.dev
// Copyright © 2022 Marcin Czachurski and the repository contributors. // Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License. // Licensed under the MIT License.
// //
@ -20,6 +20,9 @@ extension AccountData {
@NSManaged public var acct: String @NSManaged public var acct: String
@NSManaged public var avatar: URL? @NSManaged public var avatar: URL?
@NSManaged public var avatarData: Data? @NSManaged public var avatarData: Data?
@NSManaged public var clientId: String
@NSManaged public var clientSecret: String
@NSManaged public var clientVapidKey: String
@NSManaged public var createdAt: String @NSManaged public var createdAt: String
@NSManaged public var displayName: String? @NSManaged public var displayName: String?
@NSManaged public var followersCount: Int32 @NSManaged public var followersCount: Int32
@ -28,13 +31,28 @@ extension AccountData {
@NSManaged public var id: String @NSManaged public var id: String
@NSManaged public var locked: Bool @NSManaged public var locked: Bool
@NSManaged public var note: String? @NSManaged public var note: String?
@NSManaged public var serverUrl: URL
@NSManaged public var statusesCount: Int32 @NSManaged public var statusesCount: Int32
@NSManaged public var url: URL? @NSManaged public var url: URL?
@NSManaged public var username: String @NSManaged public var username: String
@NSManaged public var clientId: String @NSManaged public var statuses: Set<StatusData>?
@NSManaged public var clientSecret: String
@NSManaged public var clientVapidKey: String }
@NSManaged public var serverUrl: URL
// MARK: Generated accessors for statuses
extension AccountData {
@objc(addStatusesObject:)
@NSManaged public func addToStatuses(_ value: StatusData)
@objc(removeStatusesObject:)
@NSManaged public func removeFromStatuses(_ value: StatusData)
@objc(addStatuses:)
@NSManaged public func addToStatuses(_ values: NSSet)
@objc(removeStatuses:)
@NSManaged public func removeFromStatuses(_ values: NSSet)
} }

View File

@ -6,13 +6,14 @@
import Foundation import Foundation
import CoreData
class AccountDataHandler { class AccountDataHandler {
public static let shared = AccountDataHandler() public static let shared = AccountDataHandler()
private init() { } private init() { }
func getAccountsData() -> [AccountData] { func getAccountsData(viewContext: NSManagedObjectContext? = nil) -> [AccountData] {
let context = CoreDataHandler.shared.container.viewContext let context = viewContext ?? CoreDataHandler.shared.container.viewContext
let fetchRequest = AccountData.fetchRequest() let fetchRequest = AccountData.fetchRequest()
do { do {
return try context.fetch(fetchRequest) return try context.fetch(fetchRequest)
@ -22,8 +23,8 @@ class AccountDataHandler {
} }
} }
func getCurrentAccountData() -> AccountData? { func getCurrentAccountData(viewContext: NSManagedObjectContext? = nil) -> AccountData? {
let accounts = self.getAccountsData() let accounts = self.getAccountsData(viewContext: viewContext)
let defaultSettings = ApplicationSettingsHandler.shared.getDefaultSettings() let defaultSettings = ApplicationSettingsHandler.shared.getDefaultSettings()
let currentAccount = accounts.first { accountData in let currentAccount = accounts.first { accountData in
@ -37,6 +38,21 @@ class AccountDataHandler {
return accounts.first return accounts.first
} }
func getAccountData(accountId: String, viewContext: NSManagedObjectContext? = nil) -> AccountData? {
let context = viewContext ?? CoreDataHandler.shared.container.viewContext
let fetchRequest = AccountData.fetchRequest()
fetchRequest.fetchLimit = 1
fetchRequest.predicate = NSPredicate(format: "id = %@", accountId)
do {
return try context.fetch(fetchRequest).first
} catch {
print("Error during fetching status (getAccountData)")
return nil
}
}
func createAccountDataEntity() -> AccountData { func createAccountDataEntity() -> AccountData {
let context = CoreDataHandler.shared.container.viewContext let context = CoreDataHandler.shared.container.viewContext
return AccountData(context: context) return AccountData(context: context)

View File

@ -12,17 +12,6 @@ class AttachmentDataHandler {
public static let shared = AttachmentDataHandler() public static let shared = AttachmentDataHandler()
private init() { } private init() { }
func getAttachmentsData() -> [AttachmentData] {
let context = CoreDataHandler.shared.container.viewContext
let fetchRequest = AttachmentData.fetchRequest()
do {
return try context.fetch(fetchRequest)
} catch {
print("Error during fetching attachmens (getAttachmentsData)")
return []
}
}
func createAttachmnentDataEntity(viewContext: NSManagedObjectContext? = nil) -> AttachmentData { func createAttachmnentDataEntity(viewContext: NSManagedObjectContext? = nil) -> AttachmentData {
let context = viewContext ?? CoreDataHandler.shared.container.viewContext let context = viewContext ?? CoreDataHandler.shared.container.viewContext
return AttachmentData(context: context) return AttachmentData(context: context)

View File

@ -4,7 +4,6 @@
// Licensed under the MIT License. // Licensed under the MIT License.
// //
import CoreData import CoreData
public class CoreDataHandler { public class CoreDataHandler {

View File

@ -41,6 +41,8 @@ extension StatusData {
@NSManaged public var url: URL? @NSManaged public var url: URL?
@NSManaged public var visibility: String @NSManaged public var visibility: String
@NSManaged public var attachmentRelation: Set<AttachmentData>? @NSManaged public var attachmentRelation: Set<AttachmentData>?
@NSManaged public var pixelfedAccount: AccountData
} }
// MARK: Generated accessors for attachmentRelation // MARK: Generated accessors for attachmentRelation

View File

@ -13,23 +13,15 @@ class StatusDataHandler {
public static let shared = StatusDataHandler() public static let shared = StatusDataHandler()
private init() { } private init() { }
func getStatusesData() -> [StatusData] { func getStatusData(accountId: String, statusId: String) -> StatusData? {
let context = CoreDataHandler.shared.container.viewContext
let fetchRequest = StatusData.fetchRequest()
do {
return try context.fetch(fetchRequest)
} catch {
print("Error during fetching statuses (getStatusesData)")
return []
}
}
func getStatusData(statusId: String) -> StatusData? {
let context = CoreDataHandler.shared.container.viewContext let context = CoreDataHandler.shared.container.viewContext
let fetchRequest = StatusData.fetchRequest() let fetchRequest = StatusData.fetchRequest()
fetchRequest.fetchLimit = 1 fetchRequest.fetchLimit = 1
fetchRequest.predicate = NSPredicate(format: "id = %@", statusId) let predicate1 = NSPredicate(format: "id = %@", statusId)
let predicate2 = NSPredicate(format: "pixelfedAccount.id = %@", accountId)
fetchRequest.predicate = NSCompoundPredicate.init(type: .and, subpredicates: [predicate1,predicate2])
do { do {
return try context.fetch(fetchRequest).first return try context.fetch(fetchRequest).first
@ -39,13 +31,16 @@ class StatusDataHandler {
} }
} }
func getMaximumStatus(viewContext: NSManagedObjectContext? = nil) -> StatusData? { func getMaximumStatus(accountId: String, viewContext: NSManagedObjectContext? = nil) -> StatusData? {
let context = viewContext ?? CoreDataHandler.shared.container.viewContext let context = viewContext ?? CoreDataHandler.shared.container.viewContext
let fetchRequest = StatusData.fetchRequest() let fetchRequest = StatusData.fetchRequest()
fetchRequest.fetchLimit = 1 fetchRequest.fetchLimit = 1
let sortDescriptor = NSSortDescriptor(key: "id", ascending: false) let sortDescriptor = NSSortDescriptor(key: "id", ascending: false)
fetchRequest.sortDescriptors = [sortDescriptor] fetchRequest.sortDescriptors = [sortDescriptor]
fetchRequest.predicate = NSPredicate(format: "pixelfedAccount.id = %@", accountId)
do { do {
let statuses = try context.fetch(fetchRequest) let statuses = try context.fetch(fetchRequest)
return statuses.first return statuses.first
@ -55,13 +50,16 @@ class StatusDataHandler {
} }
} }
func getMinimumtatus(viewContext: NSManagedObjectContext? = nil) -> StatusData? { func getMinimumStatus(accountId: String, viewContext: NSManagedObjectContext? = nil) -> StatusData? {
let context = viewContext ?? CoreDataHandler.shared.container.viewContext let context = viewContext ?? CoreDataHandler.shared.container.viewContext
let fetchRequest = StatusData.fetchRequest() let fetchRequest = StatusData.fetchRequest()
fetchRequest.fetchLimit = 1 fetchRequest.fetchLimit = 1
let sortDescriptor = NSSortDescriptor(key: "id", ascending: true) let sortDescriptor = NSSortDescriptor(key: "id", ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor] fetchRequest.sortDescriptors = [sortDescriptor]
fetchRequest.predicate = NSPredicate(format: "pixelfedAccount.id = %@", accountId)
do { do {
let statuses = try context.fetch(fetchRequest) let statuses = try context.fetch(fetchRequest)
return statuses.first return statuses.first

View File

@ -8,32 +8,36 @@ import Foundation
import CoreData import CoreData
import MastodonKit import MastodonKit
public enum DatabaseError: Error {
case cannotDownloadAccount
}
public class TimelineService { public class TimelineService {
public static let shared = TimelineService() public static let shared = TimelineService()
private init() { } private init() { }
public func onBottomOfList(for accountData: AccountData) async throws { public func onBottomOfList(for accountData: AccountData) async throws -> Int {
// Load data from API and operate on CoreData on background context. // Load data from API and operate on CoreData on background context.
let backgroundContext = CoreDataHandler.shared.newBackgroundContext() let backgroundContext = CoreDataHandler.shared.newBackgroundContext()
// Get maximimum downloaded stauts id. // Get maximimum downloaded stauts id.
let oldestStatus = StatusDataHandler.shared.getMinimumtatus(viewContext: backgroundContext) let oldestStatus = StatusDataHandler.shared.getMinimumStatus(accountId: accountData.id, viewContext: backgroundContext)
guard let oldestStatus = oldestStatus else { guard let oldestStatus = oldestStatus else {
return return 0
} }
try await self.loadData(for: accountData, on: backgroundContext, maxId: oldestStatus.id) return try await self.loadData(for: accountData, on: backgroundContext, maxId: oldestStatus.id)
} }
public func onTopOfList(for accountData: AccountData) async throws { public func onTopOfList(for accountData: AccountData) async throws -> Int {
// Load data from API and operate on CoreData on background context. // Load data from API and operate on CoreData on background context.
let backgroundContext = CoreDataHandler.shared.newBackgroundContext() let backgroundContext = CoreDataHandler.shared.newBackgroundContext()
// Get maximimum downloaded stauts id. // Get maximimum downloaded stauts id.
let newestStatus = StatusDataHandler.shared.getMaximumStatus(viewContext: backgroundContext) let newestStatus = StatusDataHandler.shared.getMaximumStatus(accountId: accountData.id, viewContext: backgroundContext)
try await self.loadData(for: accountData, on: backgroundContext, minId: newestStatus?.id) return try await self.loadData(for: accountData, on: backgroundContext, minId: newestStatus?.id)
} }
public func getStatus(withId statusId: String, and accountData: AccountData?) async throws -> Status? { public func getStatus(withId statusId: String, and accountData: AccountData?) async throws -> Status? {
@ -50,9 +54,9 @@ public class TimelineService {
return try await client.getContext(for: statusId) return try await client.getContext(for: statusId)
} }
private func loadData(for accountData: AccountData, on backgroundContext: NSManagedObjectContext, minId: String? = nil, maxId: String? = nil) async throws { private func loadData(for accountData: AccountData, on backgroundContext: NSManagedObjectContext, minId: String? = nil, maxId: String? = nil) async throws -> Int {
guard let accessToken = accountData.accessToken else { guard let accessToken = accountData.accessToken else {
return return 0
} }
// Retrieve statuses from API. // Retrieve statuses from API.
@ -76,13 +80,22 @@ public class TimelineService {
} }
let statusData = StatusDataHandler.shared.createStatusDataEntity(viewContext: backgroundContext) let statusData = StatusDataHandler.shared.createStatusDataEntity(viewContext: backgroundContext)
guard let dbAccount = AccountDataHandler.shared.getAccountData(accountId: accountData.id, viewContext: backgroundContext) else {
throw DatabaseError.cannotDownloadAccount
}
statusData.pixelfedAccount = dbAccount
dbAccount.addToStatuses(statusData)
try await self.copy(from: status, to: statusData, attachmentsData: attachmentsData, on: backgroundContext) try await self.copy(from: status, to: statusData, attachmentsData: attachmentsData, on: backgroundContext)
} }
try backgroundContext.save() try backgroundContext.save()
return statuses.count
} }
public func updateStatus(_ statusData: StatusData, basedOn status: Status) async throws -> StatusData? { public func updateStatus(_ statusData: StatusData, accountData: AccountData, basedOn status: Status) async throws -> StatusData? {
// Load data from API and operate on CoreData on background context. // Load data from API and operate on CoreData on background context.
let backgroundContext = CoreDataHandler.shared.newBackgroundContext() let backgroundContext = CoreDataHandler.shared.newBackgroundContext()

View File

@ -20,6 +20,7 @@
<attribute name="statusesCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="statusesCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="url" optional="YES" attributeType="URI"/> <attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="username" attributeType="String"/> <attribute name="username" attributeType="String"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="StatusData" inverseName="pixelfedAccount" inverseEntity="StatusData"/>
</entity> </entity>
<entity name="ApplicationSettings" representedClassName="ApplicationSettings" syncable="YES"> <entity name="ApplicationSettings" representedClassName="ApplicationSettings" syncable="YES">
<attribute name="currentAccount" optional="YES" attributeType="String"/> <attribute name="currentAccount" optional="YES" attributeType="String"/>
@ -68,5 +69,6 @@
<attribute name="url" optional="YES" attributeType="URI"/> <attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibility" attributeType="String"/> <attribute name="visibility" attributeType="String"/>
<relationship name="attachmentRelation" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="AttachmentData" inverseName="statusRelation" inverseEntity="AttachmentData"/> <relationship name="attachmentRelation" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="AttachmentData" inverseName="statusRelation" inverseEntity="AttachmentData"/>
<relationship name="pixelfedAccount" maxCount="1" deletionRule="No Action" destinationEntity="AccountData" inverseName="statuses" inverseEntity="AccountData"/>
</entity> </entity>
</model> </model>

View File

@ -9,6 +9,7 @@ import MastodonKit
public class StatusViewModel { public class StatusViewModel {
public let uniqueId: UUID
public let id: StatusId public let id: StatusId
public let content: Html public let content: Html
@ -65,6 +66,7 @@ public class StatusViewModel {
tags: [Tag] = [], tags: [Tag] = [],
place: Place? = nil place: Place? = nil
) { ) {
self.uniqueId = UUID()
self.id = id self.id = id
self.content = content self.content = content
self.uri = uri self.uri = uri
@ -94,6 +96,7 @@ public class StatusViewModel {
} }
init(status: Status) { init(status: Status) {
self.uniqueId = UUID()
self.id = status.id self.id = status.id
self.content = status.content self.content = status.content
self.uri = status.uri self.uri = status.uri

View File

@ -33,12 +33,10 @@ struct FollowersView: View {
if allItemsLoaded == false && firstLoadFinished == true { if allItemsLoaded == false && firstLoadFinished == true {
LoadingIndicator() LoadingIndicator()
.onAppear { .task {
Task {
self.page = self.page + 1 self.page = self.page + 1
await self.loadAccounts(page: self.page) await self.loadAccounts(page: self.page)
} }
}
.frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center)
} }
}.overlay { }.overlay {

View File

@ -33,12 +33,10 @@ struct FollowingView: View {
if allItemsLoaded == false && firstLoadFinished == true { if allItemsLoaded == false && firstLoadFinished == true {
LoadingIndicator() LoadingIndicator()
.onAppear { .task {
Task {
self.page = self.page + 1 self.page = self.page + 1
await self.loadAccounts(page: self.page) await self.loadAccounts(page: self.page)
} }
}
.frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center)
} }
}.overlay { }.overlay {

View File

@ -10,15 +10,21 @@ struct HomeFeedView: View {
@Environment(\.managedObjectContext) private var viewContext @Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject var applicationState: ApplicationState @EnvironmentObject var applicationState: ApplicationState
@State private var showLoading = false @State private var firstLoadFinished = false
@State private var allItemsBottomLoaded = false
private static let initialColumns = 1 private static let initialColumns = 1
@State private var gridColumns = Array(repeating: GridItem(.flexible()), count: initialColumns) @State private var gridColumns = Array(repeating: GridItem(.flexible()), count: initialColumns)
@FetchRequest(sortDescriptors: [SortDescriptor(\.id, order: .reverse)]) var dbStatuses: FetchedResults<StatusData> @FetchRequest var dbStatuses: FetchedResults<StatusData>
init(accountId: String) {
_dbStatuses = FetchRequest<StatusData>(
sortDescriptors: [SortDescriptor(\.id, order: .reverse)],
predicate: NSPredicate(format: "pixelfedAccount.id = %@", accountId))
}
var body: some View { var body: some View {
ZStack {
ScrollView { ScrollView {
LazyVGrid(columns: gridColumns) { LazyVGrid(columns: gridColumns) {
ForEach(dbStatuses, id: \.self) { item in ForEach(dbStatuses, id: \.self) { item in
@ -32,12 +38,15 @@ struct HomeFeedView: View {
.buttonStyle(EmptyButtonStyle()) .buttonStyle(EmptyButtonStyle())
} }
if allItemsBottomLoaded == false && firstLoadFinished == true {
LoadingIndicator() LoadingIndicator()
.onAppear { .task {
Task {
do { do {
if let accountData = self.applicationState.accountData { if let accountData = self.applicationState.accountData {
try await TimelineService.shared.onBottomOfList(for: accountData) let newStatusesCount = try await TimelineService.shared.onBottomOfList(for: accountData)
if newStatusesCount == 0 {
allItemsBottomLoaded = true
}
} }
} catch { } catch {
print("Error", error) print("Error", error)
@ -46,15 +55,25 @@ struct HomeFeedView: View {
} }
} }
} }
.overlay(alignment: .center) {
if showLoading { if firstLoadFinished == false {
LoadingIndicator() LoadingIndicator()
} else {
if self.dbStatuses.isEmpty {
VStack {
Image(systemName: "photo.on.rectangle.angled")
.font(.largeTitle)
.padding(.bottom, 4)
Text("Unfortunately, there are no photos here.")
.font(.title3)
}.foregroundColor(.lightGrayColor)
}
} }
} }
.refreshable { .refreshable {
do { do {
if let accountData = self.applicationState.accountData { if let accountData = self.applicationState.accountData {
try await TimelineService.shared.onTopOfList(for: accountData) _ = try await TimelineService.shared.onTopOfList(for: accountData)
} }
} catch { } catch {
print("Error", error) print("Error", error)
@ -62,15 +81,20 @@ struct HomeFeedView: View {
} }
.task { .task {
do { do {
if self.dbStatuses.isEmpty { defer {
self.showLoading = true Task { @MainActor in
if let accountData = self.applicationState.accountData { self.firstLoadFinished = true
try await TimelineService.shared.onTopOfList(for: accountData)
} }
self.showLoading = false }
if self.dbStatuses.isEmpty == false {
return
}
if let accountData = self.applicationState.accountData {
_ = try await TimelineService.shared.onTopOfList(for: accountData)
} }
} catch { } catch {
self.showLoading = false
print("Error", error) print("Error", error)
} }
} }
@ -79,6 +103,6 @@ struct HomeFeedView: View {
struct HomeFeedView_Previews: PreviewProvider { struct HomeFeedView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
HomeFeedView() HomeFeedView(accountId: "")
} }
} }

View File

@ -13,6 +13,7 @@ struct MainView: View {
@Environment(\.managedObjectContext) private var viewContext @Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject var applicationState: ApplicationState @EnvironmentObject var applicationState: ApplicationState
@State private var showSettings = false
@State private var navBarTitle: String = "Home" @State private var navBarTitle: String = "Home"
@State private var viewMode: ViewMode = .home { @State private var viewMode: ViewMode = .home {
didSet { didSet {
@ -20,6 +21,8 @@ struct MainView: View {
} }
} }
@FetchRequest(sortDescriptors: [SortDescriptor(\.acct, order: .forward)]) var dbAccounts: FetchedResults<AccountData>
private enum ViewMode { private enum ViewMode {
case home, local, federated, profile, notifications case home, local, federated, profile, notifications
} }
@ -28,6 +31,9 @@ struct MainView: View {
self.getMainView() self.getMainView()
.navigationBarTitle(navBarTitle) .navigationBarTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showSettings, content: {
SettingsView()
})
.toolbar { .toolbar {
self.getLeadingToolbar() self.getLeadingToolbar()
self.getPrincipalToolbar() self.getPrincipalToolbar()
@ -38,19 +44,24 @@ struct MainView: View {
private func getMainView() -> some View { private func getMainView() -> some View {
switch self.viewMode { switch self.viewMode {
case .home: case .home:
HomeFeedView() HomeFeedView(accountId: applicationState.accountData?.id ?? "")
.id(applicationState.accountData?.id ?? "")
case .local: case .local:
LocalFeedView() LocalFeedView()
.id(applicationState.accountData?.id ?? "")
case .federated: case .federated:
FederatedFeedView() FederatedFeedView()
.id(applicationState.accountData?.id ?? "")
case .profile: case .profile:
if let accountData = self.applicationState.accountData { if let accountData = self.applicationState.accountData {
UserProfileView(accountId: accountData.id, UserProfileView(accountId: accountData.id,
accountDisplayName: accountData.displayName, accountDisplayName: accountData.displayName,
accountUserName: accountData.acct) accountUserName: accountData.acct)
.id(applicationState.accountData?.id ?? "")
} }
case .notifications: case .notifications:
NotificationsView() NotificationsView()
.id(applicationState.accountData?.id ?? "")
} }
} }
@ -121,27 +132,24 @@ struct MainView: View {
private func getLeadingToolbar() -> some ToolbarContent { private func getLeadingToolbar() -> some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Menu { Menu {
ForEach(self.dbAccounts) { account in
Button { Button {
// TODO: Switch accounts. self.applicationState.accountData = account
} label: { } label: {
HStack { if self.applicationState.accountData?.id == account.id {
Text(self.applicationState.accountData?.displayName ?? self.applicationState.accountData?.acct ?? "") Label(account.displayName ?? account.acct, systemImage: "checkmark")
Image(systemName: "person.circle.fill") } else {
.resizable() Text(account.displayName ?? account.acct)
.foregroundColor(.mainTextColor) }
} }
} }
Divider() Divider()
Button { Button {
// TODO: Open settings. self.showSettings.toggle()
} label: { } label: {
HStack { Label("Settings", systemImage: "gear")
Text("Settings")
Image(systemName: "gear")
}
} }
} label: { } label: {
if let avatarData = self.applicationState.accountData?.avatarData, let uiImage = UIImage(data: avatarData) { if let avatarData = self.applicationState.accountData?.avatarData, let uiImage = UIImage(data: avatarData) {

View File

@ -0,0 +1,64 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var applicationState: ApplicationState
@Environment(\.dismiss) private var dismiss
@State var accounts: [AccountData] = []
var body: some View {
NavigationView {
List {
Section("Accounts") {
ForEach(self.accounts) { account in
UsernameRow(accountAvatar: account.avatar,
accountDisplayName: account.displayName,
accountUsername: account.username,
cachedAvatar: CacheAvatarService.shared.getImage(for: account.id))
}
NavigationLink(destination: SignInView()) {
HStack {
Text("New account")
Spacer()
Image(systemName: "person.crop.circle.badge.plus")
}
}
}
Section("General") {
Text("Accent")
}
Section("About") {
Text("Website")
Text("License")
}
}
.frame(alignment: .topLeading)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", role: .cancel) {
dismiss()
}
}
}
.task {
self.accounts = AccountDataHandler.shared.getAccountsData()
}
.navigationBarTitle(Text("Settings"), displayMode: .inline)
}
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView()
}
}

View File

@ -8,10 +8,12 @@ import SwiftUI
struct SignInView: View { struct SignInView: View {
@Environment(\.managedObjectContext) private var viewContext @Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var applicationState: ApplicationState @EnvironmentObject var applicationState: ApplicationState
@State private var serverAddress: String = "" @State private var serverAddress: String = ""
var onSignInStateChenge: ((_ applicationViewMode: ApplicationViewMode) -> Void)? var onSignInStateChenge: ((_ applicationViewMode: ApplicationViewMode) -> Void)?
var body: some View { var body: some View {
@ -33,6 +35,7 @@ struct SignInView: View {
DispatchQueue.main.async { DispatchQueue.main.async {
self.applicationState.accountData = accountData self.applicationState.accountData = accountData
onSignInStateChenge?(.mainView) onSignInStateChenge?(.mainView)
dismiss()
} }
}) })
} }

View File

@ -88,47 +88,14 @@ struct StatusView: View {
} }
} }
} else { } else {
VStack (alignment: .leading) { StatusPlaceholder(imageHeight: self.getImageHeight(), imageBlurhash: self.imageBlurhash)
if let imageBlurhash, let uiImage = UIImage(blurHash: imageBlurhash, size: CGSize(width: 32, height: 32)) {
Image(uiImage: uiImage)
.resizable()
.frame(width: UIScreen.main.bounds.width, height: self.getImageHeight())
} else {
Rectangle()
.fill(Color.placeholderText)
.frame(width: UIScreen.main.bounds.width, height: self.getImageHeight())
.redacted(reason: .placeholder)
}
VStack(alignment: .leading) {
UsernameRow(accountDisplayName: "Verylong Displayname",
accountUsername: "@username")
Text("Lorem ispum text something")
.foregroundColor(.lightGrayColor)
.font(.footnote)
Text("Lorem ispum text something sdf sdfsdf sdfdsfsdfsdf")
.foregroundColor(.lightGrayColor)
.font(.footnote)
LabelIcon(iconName: "mappin.and.ellipse", value: "Wroclaw, Poland")
LabelIcon(iconName: "camera", value: "SONY ILCE-7M3")
LabelIcon(iconName: "camera.aperture", value: "Viltrox 24mm F1.8 E")
LabelIcon(iconName: "timelapse", value: "24.0 mm, f/1.8, 1/640s, ISO 100")
LabelIcon(iconName: "calendar", value: "2 Oct 2022")
}
.padding(8)
.redacted(reason: .placeholder)
.animatePlaceholder(isLoading: .constant(true))
}
} }
} }
.navigationBarTitle("Details") .navigationBarTitle("Details")
.sheet(isPresented: $showCompose, content: { .sheet(isPresented: $showCompose, content: {
ComposeView(statusViewModel: $messageForStatus) ComposeView(statusViewModel: $messageForStatus)
}) })
.onAppear { .task {
Task {
do { do {
// Get status from API. // Get status from API.
if let status = try await TimelineService.shared.getStatus(withId: self.statusId, and: self.applicationState.accountData) { if let status = try await TimelineService.shared.getStatus(withId: self.statusId, and: self.applicationState.accountData) {
@ -144,12 +111,10 @@ struct StatusView: View {
self.statusViewModel = statusViewModel self.statusViewModel = statusViewModel
// Get status from database.
let statusDataFromDatabase = StatusDataHandler.shared.getStatusData(statusId: self.statusId)
// If we have status in database then we can update data. // If we have status in database then we can update data.
if let statusDataFromDatabase { if let accountData = self.applicationState.accountData,
_ = try await TimelineService.shared.updateStatus(statusDataFromDatabase, basedOn: status) let statusDataFromDatabase = StatusDataHandler.shared.getStatusData(accountId: accountData.id, statusId: self.statusId) {
_ = try await TimelineService.shared.updateStatus(statusDataFromDatabase, accountData: accountData, basedOn: status)
} }
} }
} catch { } catch {
@ -157,7 +122,6 @@ struct StatusView: View {
} }
} }
} }
}
private func setAttachment(_ attachmentData: AttachmentData) { private func setAttachment(_ attachmentData: AttachmentData) {
exifCamera = attachmentData.exifCamera exifCamera = attachmentData.exifCamera

View File

@ -30,8 +30,7 @@ struct UserProfileView: View {
} }
} }
.navigationBarTitle(self.accountDisplayName ?? self.accountUserName) .navigationBarTitle(self.accountDisplayName ?? self.accountUserName)
.onAppear { .task {
Task {
do { do {
try await self.loadData() try await self.loadData()
} catch { } catch {
@ -39,7 +38,6 @@ struct UserProfileView: View {
} }
} }
} }
}
private func loadData() async throws { private func loadData() async throws {
async let relationshipTask = AccountService.shared.getRelationship(withId: self.accountId, forUser: self.applicationState.accountData) async let relationshipTask = AccountService.shared.getRelationship(withId: self.accountId, forUser: self.applicationState.accountData)

View File

@ -0,0 +1,55 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import SwiftUI
struct StatusPlaceholder: View {
@State var imageHeight: Double
@State var imageBlurhash: String?
var body: some View {
VStack (alignment: .leading) {
if let imageBlurhash, let uiImage = UIImage(blurHash: imageBlurhash, size: CGSize(width: 32, height: 32)) {
Image(uiImage: uiImage)
.resizable()
.frame(width: UIScreen.main.bounds.width, height: imageHeight)
} else {
Rectangle()
.fill(Color.placeholderText)
.frame(width: UIScreen.main.bounds.width, height: imageHeight)
.redacted(reason: .placeholder)
}
VStack(alignment: .leading) {
UsernameRow(accountDisplayName: "Verylong Displayname",
accountUsername: "@username")
Text("Lorem ispum text something")
.foregroundColor(.lightGrayColor)
.font(.footnote)
Text("Lorem ispum text something sdf sdfsdf sdfdsfsdfsdf")
.foregroundColor(.lightGrayColor)
.font(.footnote)
LabelIcon(iconName: "mappin.and.ellipse", value: "Wroclaw, Poland")
LabelIcon(iconName: "camera", value: "SONY ILCE-7M3")
LabelIcon(iconName: "camera.aperture", value: "Viltrox 24mm F1.8 E")
LabelIcon(iconName: "timelapse", value: "24.0 mm, f/1.8, 1/640s, ISO 100")
LabelIcon(iconName: "calendar", value: "2 Oct 2022")
}
.padding(8)
.redacted(reason: .placeholder)
.animatePlaceholder(isLoading: .constant(true))
}
}
}
struct StatusPlaceholder_Previews: PreviewProvider {
static var previews: some View {
StatusPlaceholder(imageHeight: 100.0)
.previewLayout(.fixed(width: 320, height: 320))
}
}

View File

@ -20,7 +20,7 @@ struct UserProfileStatuses: View {
var body: some View { var body: some View {
VStack(alignment: .center) { VStack(alignment: .center) {
if firstLoadFinished == true { if firstLoadFinished == true {
ForEach(self.statusViewModels, id: \.id) { item in ForEach(self.statusViewModels, id: \.uniqueId) { item in
NavigationLink(destination: StatusView(statusId: item.id, NavigationLink(destination: StatusView(statusId: item.id,
imageBlurhash: item.mediaAttachments.first?.blurhash, imageBlurhash: item.mediaAttachments.first?.blurhash,
imageWidth: item.getImageWidth(), imageWidth: item.getImageWidth(),
@ -35,15 +35,13 @@ struct UserProfileStatuses: View {
LazyVStack { LazyVStack {
if allItemsLoaded == false && firstLoadFinished == true { if allItemsLoaded == false && firstLoadFinished == true {
LoadingIndicator() LoadingIndicator()
.onAppear { .task {
Task {
do { do {
try await self.loadMoreStatuses() try await self.loadMoreStatuses()
} catch { } catch {
print("Error \(error.localizedDescription)") print("Error \(error.localizedDescription)")
} }
} }
}
.frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center)
} }
} }
@ -51,8 +49,7 @@ struct UserProfileStatuses: View {
} else { } else {
LoadingIndicator() LoadingIndicator()
} }
}.onAppear { }.task {
Task {
do { do {
try await self.loadStatuses() try await self.loadStatuses()
} catch { } catch {
@ -60,7 +57,6 @@ struct UserProfileStatuses: View {
} }
} }
} }
}
private func loadStatuses() async throws { private func loadStatuses() async throws {
let statuses = try await AccountService.shared.getStatuses( let statuses = try await AccountService.shared.getStatuses(