feat: Add Kingfisher caching, card layouts, dynamic tag layout, and undo delete
- Integrate Kingfisher for image caching with CachedAsyncImage component - Add CacheSettingsView for managing image cache size and clearing cache - Implement three card layout styles: compact, magazine (default), natural - Add AppearanceSettingsView with visual layout previews and theme settings - Create Clean Architecture for card layout with domain models and use cases - Implement FlowLayout for dynamic label width calculation - Add skeleton loading animation for initial bookmark loads - Replace delete confirmation dialogs with immediate delete + 3-second undo - Support multiple simultaneous undo operations with individual progress bars - Add grayed-out visual feedback for pending deletions - Centralize notification names in dedicated NotificationNames file - Remove pagination logic from label management (replaced with FlowLayout) - Update AsyncImage usage across BookmarkCardView, BookmarkDetailView, ImageViewerView - Improve UI consistency and spacing throughout the app
This commit is contained in:
parent
680a9562be
commit
df8a7b64b2
@ -6,7 +6,7 @@ struct ShareBookmarkView: View {
|
|||||||
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
@FocusState private var focusedField: AddBookmarkFieldFocus?
|
||||||
|
|
||||||
private func dismissKeyboard() {
|
private func dismissKeyboard() {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil)
|
NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -140,7 +140,6 @@ struct ShareBookmarkView: View {
|
|||||||
selectedLabels: viewModel.selectedLabels,
|
selectedLabels: viewModel.selectedLabels,
|
||||||
searchText: $viewModel.searchText,
|
searchText: $viewModel.searchText,
|
||||||
isLabelsLoading: false,
|
isLabelsLoading: false,
|
||||||
availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages),
|
|
||||||
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
|
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
|
||||||
searchFieldFocus: $focusedField,
|
searchFieldFocus: $focusedField,
|
||||||
onAddCustomTag: {
|
onAddCustomTag: {
|
||||||
|
|||||||
@ -15,13 +15,12 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
let extensionContext: NSExtensionContext?
|
let extensionContext: NSExtensionContext?
|
||||||
|
|
||||||
private let logger = Logger.viewModel
|
private let logger = Logger.viewModel
|
||||||
|
|
||||||
// Computed properties for pagination
|
|
||||||
var availableLabels: [BookmarkLabelDto] {
|
var availableLabels: [BookmarkLabelDto] {
|
||||||
return labels.filter { !selectedLabels.contains($0.name) }
|
return labels.filter { !selectedLabels.contains($0.name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed property for filtered labels based on search text
|
// filtered labels based on search text
|
||||||
var filteredLabels: [BookmarkLabelDto] {
|
var filteredLabels: [BookmarkLabelDto] {
|
||||||
if searchText.isEmpty {
|
if searchText.isEmpty {
|
||||||
return availableLabels
|
return availableLabels
|
||||||
|
|||||||
@ -34,7 +34,7 @@ class ShareViewController: UIViewController {
|
|||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(dismissKeyboard),
|
selector: #selector(dismissKeyboard),
|
||||||
name: NSNotification.Name("DismissKeyboard"),
|
name: .dismissKeyboard,
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,7 +41,7 @@ class SimpleAPI {
|
|||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
if httpResponse.statusCode == 401 {
|
if httpResponse.statusCode == 401 {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
|
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
@ -94,7 +94,7 @@ class SimpleAPI {
|
|||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
if httpResponse.statusCode == 401 {
|
if httpResponse.statusCode == 401 {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
|
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
||||||
|
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
|
||||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@ -90,6 +91,7 @@
|
|||||||
UI/Components/CustomTextFieldStyle.swift,
|
UI/Components/CustomTextFieldStyle.swift,
|
||||||
UI/Components/TagManagementView.swift,
|
UI/Components/TagManagementView.swift,
|
||||||
UI/Components/UnifiedLabelChip.swift,
|
UI/Components/UnifiedLabelChip.swift,
|
||||||
|
UI/Utils/NotificationNames.swift,
|
||||||
);
|
);
|
||||||
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
||||||
};
|
};
|
||||||
@ -144,6 +146,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
|
||||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
||||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
||||||
);
|
);
|
||||||
@ -236,6 +239,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
||||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
||||||
|
5D9D95482E623668009AF769 /* Kingfisher */,
|
||||||
);
|
);
|
||||||
productName = readeck;
|
productName = readeck;
|
||||||
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
||||||
@ -326,6 +330,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
||||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
||||||
|
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
||||||
@ -847,6 +852,14 @@
|
|||||||
minimumVersion = 1.21.0;
|
minimumVersion = 1.21.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 8.5.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
|
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
|
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
|
||||||
@ -863,6 +876,11 @@
|
|||||||
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
|
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
|
||||||
productName = netfox;
|
productName = netfox;
|
||||||
};
|
};
|
||||||
|
5D9D95482E623668009AF769 /* Kingfisher */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||||
|
productName = Kingfisher;
|
||||||
|
};
|
||||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
|
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
|
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
|
"originHash" : "23641a762ee1f352c85f7c3a1e980d54670907541f34888222e901374fcaa088",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "kingfisher",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/onevcat/Kingfisher.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2015fda791daa72c8058619545a593bf8c1dd59f",
|
||||||
|
"version" : "8.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "netfox",
|
"identity" : "netfox",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@ -2,16 +2,7 @@
|
|||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png",
|
"filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal"
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@ -45,7 +45,7 @@ class API: PAPI {
|
|||||||
private func handleUnauthorizedResponse(_ statusCode: Int) {
|
private func handleUnauthorizedResponse(_ statusCode: Int) {
|
||||||
if statusCode == 401 {
|
if statusCode == 401 {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
|
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,7 +119,7 @@ class OfflineSyncManager: ObservableObject {
|
|||||||
func startAutoSync() {
|
func startAutoSync() {
|
||||||
// Monitor server connectivity and auto-sync when server becomes reachable
|
// Monitor server connectivity and auto-sync when server becomes reachable
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: NSNotification.Name("ServerDidBecomeAvailable"),
|
forName: .serverDidBecomeAvailable,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
|
|||||||
@ -12,6 +12,7 @@ struct Settings {
|
|||||||
var hasFinishedSetup: Bool = false
|
var hasFinishedSetup: Bool = false
|
||||||
var enableTTS: Bool? = nil
|
var enableTTS: Bool? = nil
|
||||||
var theme: Theme? = nil
|
var theme: Theme? = nil
|
||||||
|
var cardLayoutStyle: CardLayoutStyle? = nil
|
||||||
|
|
||||||
var isLoggedIn: Bool {
|
var isLoggedIn: Bool {
|
||||||
token != nil && !token!.isEmpty
|
token != nil && !token!.isEmpty
|
||||||
@ -31,6 +32,8 @@ protocol PSettingsRepository {
|
|||||||
func savePassword(_ password: String) async throws
|
func savePassword(_ password: String) async throws
|
||||||
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
|
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
|
||||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
||||||
|
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
|
||||||
|
func loadCardLayoutStyle() async throws -> CardLayoutStyle
|
||||||
var hasFinishedSetup: Bool { get }
|
var hasFinishedSetup: Bool { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +82,10 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
existingSettings.theme = theme.rawValue
|
existingSettings.theme = theme.rawValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let cardLayoutStyle = settings.cardLayoutStyle {
|
||||||
|
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
try context.save()
|
try context.save()
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
} catch {
|
} catch {
|
||||||
@ -115,7 +122,8 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
|
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
|
||||||
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
|
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
|
||||||
enableTTS: settingEntity?.enableTTS,
|
enableTTS: settingEntity?.enableTTS,
|
||||||
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue)
|
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue),
|
||||||
|
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue)
|
||||||
)
|
)
|
||||||
continuation.resume(returning: settings)
|
continuation.resume(returning: settings)
|
||||||
} catch {
|
} catch {
|
||||||
@ -160,7 +168,7 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
self.hasFinishedSetup = true
|
self.hasFinishedSetup = true
|
||||||
// Notification senden, dass sich der Setup-Status geändert hat
|
// Notification senden, dass sich der Setup-Status geändert hat
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -174,7 +182,7 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
if !token.isEmpty {
|
if !token.isEmpty {
|
||||||
self.hasFinishedSetup = true
|
self.hasFinishedSetup = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -192,7 +200,7 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
self.hasFinishedSetup = hasFinishedSetup
|
self.hasFinishedSetup = hasFinishedSetup
|
||||||
// Notification senden, dass sich der Setup-Status geändert hat
|
// Notification senden, dass sich der Setup-Status geändert hat
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
}
|
}
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
}
|
}
|
||||||
@ -206,4 +214,45 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
userDefault.set(newValue, forKey: "hasFinishedSetup")
|
userDefault.set(newValue, forKey: "hasFinishedSetup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {
|
||||||
|
let context = coreDataManager.context
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
context.perform {
|
||||||
|
do {
|
||||||
|
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||||
|
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
|
||||||
|
|
||||||
|
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
|
||||||
|
|
||||||
|
try context.save()
|
||||||
|
continuation.resume()
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCardLayoutStyle() async throws -> CardLayoutStyle {
|
||||||
|
let context = coreDataManager.context
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
context.perform {
|
||||||
|
do {
|
||||||
|
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let settingEntities = try context.fetch(fetchRequest)
|
||||||
|
let settingEntity = settingEntities.first
|
||||||
|
|
||||||
|
let cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine
|
||||||
|
continuation.resume(returning: cardLayoutStyle)
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class ServerConnectivity: ObservableObject {
|
|||||||
|
|
||||||
// Notify when server becomes available
|
// Notify when server becomes available
|
||||||
if !wasReachable && serverReachable {
|
if !wasReachable && serverReachable {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("ServerDidBecomeAvailable"), object: nil)
|
NotificationCenter.default.post(name: .serverDidBecomeAvailable, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
readeck/Domain/Model/CardLayoutStyle.swift
Normal file
29
readeck/Domain/Model/CardLayoutStyle.swift
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CardLayoutStyle: String, CaseIterable, Codable {
|
||||||
|
case compact = "compact"
|
||||||
|
case magazine = "magazine"
|
||||||
|
case natural = "natural"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .compact:
|
||||||
|
return "Compact"
|
||||||
|
case .magazine:
|
||||||
|
return "Magazine"
|
||||||
|
case .natural:
|
||||||
|
return "Natural"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .compact:
|
||||||
|
return "Small thumbnails with content focus"
|
||||||
|
case .magazine:
|
||||||
|
return "Fixed height headers for consistent layout"
|
||||||
|
case .natural:
|
||||||
|
return "Images in original aspect ratio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
readeck/Domain/UseCase/LoadCardLayoutUseCase.swift
Normal file
21
readeck/Domain/UseCase/LoadCardLayoutUseCase.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PLoadCardLayoutUseCase {
|
||||||
|
func execute() async -> CardLayoutStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoadCardLayoutUseCase: PLoadCardLayoutUseCase {
|
||||||
|
private let settingsRepository: PSettingsRepository
|
||||||
|
|
||||||
|
init(settingsRepository: PSettingsRepository) {
|
||||||
|
self.settingsRepository = settingsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute() async -> CardLayoutStyle {
|
||||||
|
do {
|
||||||
|
return try await settingsRepository.loadCardLayoutStyle()
|
||||||
|
} catch {
|
||||||
|
return .magazine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
readeck/Domain/UseCase/SaveCardLayoutUseCase.swift
Normal file
22
readeck/Domain/UseCase/SaveCardLayoutUseCase.swift
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PSaveCardLayoutUseCase {
|
||||||
|
func execute(layout: CardLayoutStyle) async
|
||||||
|
}
|
||||||
|
|
||||||
|
class SaveCardLayoutUseCase: PSaveCardLayoutUseCase {
|
||||||
|
private let settingsRepository: PSettingsRepository
|
||||||
|
private let logger = Logger.data
|
||||||
|
|
||||||
|
init(settingsRepository: PSettingsRepository) {
|
||||||
|
self.settingsRepository = settingsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(layout: CardLayoutStyle) async {
|
||||||
|
do {
|
||||||
|
try await settingsRepository.saveCardLayoutStyle(layout)
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to save card layout style: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -74,11 +74,9 @@ struct AddBookmarkView: View {
|
|||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 16) {
|
||||||
urlField
|
urlField
|
||||||
.id("urlField")
|
.id("urlField")
|
||||||
Spacer()
|
|
||||||
.frame(height: 40)
|
|
||||||
.id("labelsOffset")
|
.id("labelsOffset")
|
||||||
labelsField
|
labelsField
|
||||||
.id("labelsField")
|
.id("labelsField")
|
||||||
@ -160,10 +158,11 @@ struct AddBookmarkView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(12)
|
||||||
.background(Color(.systemGray6))
|
.background(Color(.systemGray6))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +182,6 @@ struct AddBookmarkView: View {
|
|||||||
selectedLabels: viewModel.selectedLabels,
|
selectedLabels: viewModel.selectedLabels,
|
||||||
searchText: $viewModel.searchText,
|
searchText: $viewModel.searchText,
|
||||||
isLabelsLoading: viewModel.isLabelsLoading,
|
isLabelsLoading: viewModel.isLabelsLoading,
|
||||||
availableLabelPages: viewModel.availableLabelPages,
|
|
||||||
filteredLabels: viewModel.filteredLabels,
|
filteredLabels: viewModel.filteredLabels,
|
||||||
searchFieldFocus: $focusedField,
|
searchFieldFocus: $focusedField,
|
||||||
onAddCustomTag: {
|
onAddCustomTag: {
|
||||||
|
|||||||
@ -59,19 +59,6 @@ class AddBookmarkViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var availableLabelPages: [[BookmarkLabel]] {
|
|
||||||
let pageSize = Constants.Labels.pageSize
|
|
||||||
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
|
|
||||||
|
|
||||||
if labelsToShow.count <= pageSize {
|
|
||||||
return [labelsToShow]
|
|
||||||
} else {
|
|
||||||
return stride(from: 0, to: labelsToShow.count, by: pageSize).map {
|
|
||||||
Array(labelsToShow[$0..<min($0 + pageSize, labelsToShow.count)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Labels Management
|
// MARK: - Labels Management
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class AppViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func setupNotificationObservers() {
|
private func setupNotificationObservers() {
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: NSNotification.Name("UnauthorizedAPIResponse"),
|
forName: .unauthorizedAPIResponse,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
@ -35,7 +35,7 @@ class AppViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: NSNotification.Name("SetupStatusChanged"),
|
forName: .setupStatusChanged,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import Combine
|
|||||||
|
|
||||||
struct BookmarkDetailView: View {
|
struct BookmarkDetailView: View {
|
||||||
let bookmarkId: String
|
let bookmarkId: String
|
||||||
let namespace: Namespace.ID?
|
|
||||||
|
|
||||||
// MARK: - States
|
// MARK: - States
|
||||||
|
|
||||||
@ -24,11 +23,10 @@ struct BookmarkDetailView: View {
|
|||||||
@EnvironmentObject var appSettings: AppSettings
|
@EnvironmentObject var appSettings: AppSettings
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
private let headerHeight: CGFloat = 320
|
private let headerHeight: CGFloat = 360
|
||||||
|
|
||||||
init(bookmarkId: String, namespace: Namespace.ID? = nil, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
|
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
|
||||||
self.bookmarkId = bookmarkId
|
self.bookmarkId = bookmarkId
|
||||||
self.namespace = namespace
|
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.webViewHeight = webViewHeight
|
self.webViewHeight = webViewHeight
|
||||||
self.showingFontSettings = showingFontSettings
|
self.showingFontSettings = showingFontSettings
|
||||||
@ -66,7 +64,7 @@ struct BookmarkDetailView: View {
|
|||||||
})
|
})
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
.cornerRadius(14)
|
.cornerRadius(14)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal, 4)
|
||||||
.animation(.easeInOut, value: webViewHeight)
|
.animation(.easeInOut, value: webViewHeight)
|
||||||
} else if viewModel.isLoadingArticle {
|
} else if viewModel.isLoadingArticle {
|
||||||
ProgressView("Loading article...")
|
ProgressView("Loading article...")
|
||||||
@ -191,40 +189,11 @@ struct BookmarkDetailView: View {
|
|||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let offset = geo.frame(in: .global).minY
|
let offset = geo.frame(in: .global).minY
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
|
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||||
image
|
.scaledToFit()
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
||||||
.clipped()
|
.clipped()
|
||||||
.offset(y: (offset > 0 ? -offset : 0))
|
.offset(y: (offset > 0 ? -offset : 0))
|
||||||
.if(namespace != nil) { view in
|
|
||||||
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
|
|
||||||
}
|
|
||||||
} placeholder: {
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.gray.opacity(0.4))
|
|
||||||
.frame(width: geometry.size.width, height: headerHeight)
|
|
||||||
.if(namespace != nil) { view in
|
|
||||||
view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Gradient overlay für bessere Button-Sichtbarkeit
|
|
||||||
LinearGradient(
|
|
||||||
gradient: Gradient(colors: [
|
|
||||||
Color.black.opacity(1.0),
|
|
||||||
Color.black.opacity(0.9),
|
|
||||||
Color.black.opacity(0.7),
|
|
||||||
Color.black.opacity(0.4),
|
|
||||||
Color.black.opacity(0.2),
|
|
||||||
Color.clear
|
|
||||||
]),
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
.frame(height: 240)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.offset(y: (offset > 0 ? -offset : 0))
|
|
||||||
|
|
||||||
// Tap area and zoom icon
|
// Tap area and zoom icon
|
||||||
VStack {
|
VStack {
|
||||||
|
|||||||
@ -61,7 +61,6 @@ struct BookmarkLabelsView: View {
|
|||||||
selectedLabels: Set(viewModel.currentLabels),
|
selectedLabels: Set(viewModel.currentLabels),
|
||||||
searchText: $viewModel.searchText,
|
searchText: $viewModel.searchText,
|
||||||
isLabelsLoading: viewModel.isInitialLoading,
|
isLabelsLoading: viewModel.isInitialLoading,
|
||||||
availableLabelPages: viewModel.availableLabelPages,
|
|
||||||
filteredLabels: viewModel.filteredLabels,
|
filteredLabels: viewModel.filteredLabels,
|
||||||
onAddCustomTag: {
|
onAddCustomTag: {
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@ -10,46 +10,24 @@ class BookmarkLabelsViewModel {
|
|||||||
var isInitialLoading = false
|
var isInitialLoading = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var showErrorAlert = false
|
var showErrorAlert = false
|
||||||
var currentLabels: [String] = [] {
|
var currentLabels: [String] = []
|
||||||
didSet {
|
|
||||||
if oldValue != currentLabels {
|
|
||||||
calculatePages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var newLabelText = ""
|
var newLabelText = ""
|
||||||
var searchText = "" {
|
var searchText = ""
|
||||||
didSet {
|
|
||||||
if oldValue != searchText {
|
|
||||||
calculatePages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var allLabels: [BookmarkLabel] = [] {
|
var allLabels: [BookmarkLabel] = []
|
||||||
didSet {
|
|
||||||
if oldValue != allLabels {
|
|
||||||
calculatePages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var labelPages: [[BookmarkLabel]] = []
|
|
||||||
|
|
||||||
// Cached properties to avoid recomputation
|
|
||||||
private var _availableLabels: [BookmarkLabel] = []
|
|
||||||
private var _filteredLabels: [BookmarkLabel] = []
|
|
||||||
|
|
||||||
var availableLabels: [BookmarkLabel] {
|
var availableLabels: [BookmarkLabel] {
|
||||||
return _availableLabels
|
return allLabels.filter { !currentLabels.contains($0.name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var filteredLabels: [BookmarkLabel] {
|
var filteredLabels: [BookmarkLabel] {
|
||||||
return _filteredLabels
|
if searchText.isEmpty {
|
||||||
|
return availableLabels
|
||||||
|
} else {
|
||||||
|
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var availableLabelPages: [[BookmarkLabel]] = []
|
|
||||||
|
|
||||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
|
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
|
||||||
self.currentLabels = initialLabels
|
self.currentLabels = initialLabels
|
||||||
|
|
||||||
@ -70,8 +48,6 @@ class BookmarkLabelsViewModel {
|
|||||||
errorMessage = "failed to load labels"
|
errorMessage = "failed to load labels"
|
||||||
showErrorAlert = true
|
showErrorAlert = true
|
||||||
}
|
}
|
||||||
|
|
||||||
calculatePages()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -143,36 +119,4 @@ class BookmarkLabelsViewModel {
|
|||||||
func updateLabels(_ labels: [String]) {
|
func updateLabels(_ labels: [String]) {
|
||||||
currentLabels = labels
|
currentLabels = labels
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculatePages() {
|
|
||||||
let pageSize = Constants.Labels.pageSize
|
|
||||||
|
|
||||||
// Update cached available labels
|
|
||||||
_availableLabels = allLabels.filter { !currentLabels.contains($0.name) }
|
|
||||||
|
|
||||||
// Update cached filtered labels
|
|
||||||
if searchText.isEmpty {
|
|
||||||
_filteredLabels = _availableLabels
|
|
||||||
} else {
|
|
||||||
_filteredLabels = _availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate pages for all labels
|
|
||||||
if allLabels.count <= pageSize {
|
|
||||||
labelPages = [allLabels]
|
|
||||||
} else {
|
|
||||||
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
|
|
||||||
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate pages for filtered labels
|
|
||||||
if _filteredLabels.count <= pageSize {
|
|
||||||
availableLabelPages = [_filteredLabels]
|
|
||||||
} else {
|
|
||||||
availableLabelPages = stride(from: 0, to: _filteredLabels.count, by: pageSize).map {
|
|
||||||
Array(_filteredLabels[$0..<min($0 + pageSize, _filteredLabels.count)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,102 +17,91 @@ struct ImageViewerView: View {
|
|||||||
Color.black
|
Color.black
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
AsyncImage(url: URL(string: imageUrl)) { image in
|
CachedAsyncImage(url: URL(string: imageUrl))
|
||||||
image
|
.scaledToFit()
|
||||||
.resizable()
|
.scaleEffect(scale)
|
||||||
.scaledToFit()
|
.offset(offset)
|
||||||
.scaleEffect(scale)
|
.offset(dragOffset)
|
||||||
.offset(offset)
|
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
|
||||||
.offset(dragOffset)
|
.gesture(
|
||||||
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
|
SimultaneousGesture(
|
||||||
.gesture(
|
MagnificationGesture()
|
||||||
SimultaneousGesture(
|
.onChanged { value in
|
||||||
MagnificationGesture()
|
let delta = value / lastScale
|
||||||
.onChanged { value in
|
lastScale = value
|
||||||
let delta = value / lastScale
|
scale = min(max(scale * delta, 1), 4)
|
||||||
lastScale = value
|
|
||||||
scale = min(max(scale * delta, 1), 4)
|
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
lastScale = 1.0
|
|
||||||
if scale < 1 {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
scale = 1
|
|
||||||
offset = .zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if scale > 4 {
|
|
||||||
scale = 4
|
|
||||||
}
|
|
||||||
},
|
|
||||||
DragGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
if scale > 1 {
|
|
||||||
let newOffset = CGSize(
|
|
||||||
width: lastOffset.width + value.translation.width,
|
|
||||||
height: lastOffset.height + value.translation.height
|
|
||||||
)
|
|
||||||
offset = newOffset
|
|
||||||
} else {
|
|
||||||
// Dismiss gesture when not zoomed
|
|
||||||
dragOffset = value.translation
|
|
||||||
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
|
||||||
if dragDistance > 50 {
|
|
||||||
isDraggingToDismiss = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { value in
|
|
||||||
if scale <= 1 {
|
|
||||||
lastOffset = offset
|
|
||||||
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
|
||||||
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
|
|
||||||
|
|
||||||
if dragDistance > 100 || velocity > 500 {
|
|
||||||
dismiss()
|
|
||||||
} else {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
dragOffset = .zero
|
|
||||||
isDraggingToDismiss = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lastOffset = offset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.onTapGesture(count: 2) {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
if scale > 1 {
|
|
||||||
scale = 1
|
|
||||||
offset = .zero
|
|
||||||
lastOffset = .zero
|
|
||||||
} else {
|
|
||||||
scale = 2
|
|
||||||
}
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastScale = 1.0
|
||||||
|
if scale < 1 {
|
||||||
|
withAnimation(.spring()) {
|
||||||
|
scale = 1
|
||||||
|
offset = .zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scale > 4 {
|
||||||
|
scale = 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
if scale > 1 {
|
||||||
|
let newOffset = CGSize(
|
||||||
|
width: lastOffset.width + value.translation.width,
|
||||||
|
height: lastOffset.height + value.translation.height
|
||||||
|
)
|
||||||
|
offset = newOffset
|
||||||
|
} else {
|
||||||
|
// Dismiss gesture when not zoomed
|
||||||
|
dragOffset = value.translation
|
||||||
|
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
||||||
|
if dragDistance > 50 {
|
||||||
|
isDraggingToDismiss = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
if scale <= 1 {
|
||||||
|
lastOffset = offset
|
||||||
|
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
|
||||||
|
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
|
||||||
|
|
||||||
|
if dragDistance > 100 || velocity > 500 {
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
withAnimation(.spring()) {
|
||||||
|
dragOffset = .zero
|
||||||
|
isDraggingToDismiss = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastOffset = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
withAnimation(.spring()) {
|
||||||
|
if scale > 1 {
|
||||||
|
scale = 1
|
||||||
|
offset = .zero
|
||||||
|
lastOffset = .zero
|
||||||
|
} else {
|
||||||
|
scale = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} placeholder: {
|
}
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(1.5)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.toolbar {
|
||||||
.toolbar {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
Button("Close") {
|
||||||
Button("Close") {
|
dismiss()
|
||||||
dismiss()
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ImageViewerView(imageUrl: "https://example.com/image.jpg")
|
|
||||||
}
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
import SafariServices
|
import SafariServices
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
@ -12,35 +13,200 @@ extension View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct BookmarkCardView: View {
|
struct BookmarkCardView: View {
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
let bookmark: Bookmark
|
let bookmark: Bookmark
|
||||||
let currentState: BookmarkState
|
let currentState: BookmarkState
|
||||||
|
let layout: CardLayoutStyle
|
||||||
|
let pendingDelete: PendingDelete?
|
||||||
let onArchive: (Bookmark) -> Void
|
let onArchive: (Bookmark) -> Void
|
||||||
let onDelete: (Bookmark) -> Void
|
let onDelete: (Bookmark) -> Void
|
||||||
let onToggleFavorite: (Bookmark) -> Void
|
let onToggleFavorite: (Bookmark) -> Void
|
||||||
let namespace: Namespace.ID?
|
let onUndoDelete: ((String) -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
bookmark: Bookmark,
|
||||||
|
currentState: BookmarkState,
|
||||||
|
layout: CardLayoutStyle = .magazine,
|
||||||
|
pendingDelete: PendingDelete? = nil,
|
||||||
|
onArchive: @escaping (Bookmark) -> Void,
|
||||||
|
onDelete: @escaping (Bookmark) -> Void,
|
||||||
|
onToggleFavorite: @escaping (Bookmark) -> Void,
|
||||||
|
onUndoDelete: ((String) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.bookmark = bookmark
|
||||||
|
self.currentState = currentState
|
||||||
|
self.layout = layout
|
||||||
|
self.pendingDelete = pendingDelete
|
||||||
|
self.onArchive = onArchive
|
||||||
|
self.onDelete = onDelete
|
||||||
|
self.onToggleFavorite = onToggleFavorite
|
||||||
|
self.onUndoDelete = onUndoDelete
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
Group {
|
||||||
|
switch layout {
|
||||||
|
case .compact:
|
||||||
|
compactLayoutView
|
||||||
|
case .magazine:
|
||||||
|
magazineLayoutView
|
||||||
|
case .natural:
|
||||||
|
naturalLayoutView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.opacity(pendingDelete != nil ? 0.4 : 1.0)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: pendingDelete != nil)
|
||||||
|
|
||||||
|
// Undo button overlay
|
||||||
|
if let pendingDelete = pendingDelete {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Undo button area - only when user interacts
|
||||||
|
HStack {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption2)
|
||||||
|
|
||||||
|
Text("Deleting...")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Undo") {
|
||||||
|
onUndoDelete?(bookmark.id)
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.onTapGesture {
|
||||||
|
onUndoDelete?(bookmark.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color(.systemBackground).opacity(0.95))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: layout == .compact ? 8 : 12))
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress Bar am unteren Rand
|
||||||
|
if let pendingDelete = pendingDelete {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Progress Bar
|
||||||
|
ProgressView(value: pendingDelete.progress, total: 1.0)
|
||||||
|
.progressViewStyle(LinearProgressViewStyle(tint: .red))
|
||||||
|
.scaleEffect(x: 1, y: 1.5, anchor: .center)
|
||||||
|
.clipShape(
|
||||||
|
.rect(
|
||||||
|
bottomLeadingRadius: layout == .compact ? 8 : 12,
|
||||||
|
bottomTrailingRadius: layout == .compact ? 8 : 12
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
if pendingDelete == nil {
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
onDelete(bookmark)
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
|
if pendingDelete == nil {
|
||||||
|
Button {
|
||||||
|
onArchive(bookmark)
|
||||||
|
} label: {
|
||||||
|
if currentState == .archived {
|
||||||
|
Label("Restore", systemImage: "tray.and.arrow.up")
|
||||||
|
} else {
|
||||||
|
Label("Archive", systemImage: "archivebox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(currentState == .archived ? .blue : .orange)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onToggleFavorite(bookmark)
|
||||||
|
} label: {
|
||||||
|
Label(bookmark.isMarked ? "Remove" : "Favorite",
|
||||||
|
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
|
||||||
|
}
|
||||||
|
.tint(bookmark.isMarked ? .gray : .pink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var compactLayoutView: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
CachedAsyncImage(url: imageURL)
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(bookmark.title)
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
if !bookmark.description.isEmpty {
|
||||||
|
Text(bookmark.description)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if !bookmark.siteName.isEmpty {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "globe")
|
||||||
|
Text(bookmark.siteName)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
Text("\(readingTime) min")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var magazineLayoutView: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
AsyncImage(url: imageURL) { image in
|
CachedAsyncImage(url: imageURL)
|
||||||
image
|
.aspectRatio(contentMode: .fill)
|
||||||
.resizable()
|
.frame(height: 140)
|
||||||
.aspectRatio(contentMode: .fill)
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
.frame(height: 120)
|
|
||||||
} placeholder: {
|
|
||||||
|
|
||||||
Image(R.image.placeholder.name)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(height: 120)
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.if(namespace != nil) { view in
|
|
||||||
view.matchedGeometryEffect(id: "image-\(bookmark.id)", in: namespace!)
|
|
||||||
}
|
|
||||||
|
|
||||||
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
||||||
ZStack {
|
ZStack {
|
||||||
@ -77,15 +243,12 @@ struct BookmarkCardView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
||||||
// Published date
|
|
||||||
if let publishedDate = formattedPublishedDate {
|
if let publishedDate = formattedPublishedDate {
|
||||||
HStack {
|
HStack {
|
||||||
Label(publishedDate, systemImage: "calendar")
|
Label(publishedDate, systemImage: "calendar")
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
Spacer() // show spacer only if we have the published Date
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||||
@ -107,41 +270,93 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
.background(Color(R.color.bookmark_list_bg))
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
.shadow(color: colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.1), radius: 2, x: 0, y: 1)
|
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||||
Button("Delete", role: .destructive) {
|
}
|
||||||
onDelete(bookmark)
|
|
||||||
}
|
private var naturalLayoutView: some View {
|
||||||
.tint(.red)
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
}
|
ZStack(alignment: .bottomTrailing) {
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
CachedAsyncImage(url: imageURL)
|
||||||
// Archive (left)
|
.aspectRatio(contentMode: .fit)
|
||||||
Button {
|
.frame(minHeight: 180)
|
||||||
onArchive(bookmark)
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
} label: {
|
|
||||||
if currentState == .archived {
|
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
||||||
Label("Restore", systemImage: "tray.and.arrow.up")
|
ZStack {
|
||||||
} else {
|
Circle()
|
||||||
Label("Archive", systemImage: "archivebox")
|
.fill(Color(.systemBackground))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(bookmark.readProgress) / 100)
|
||||||
|
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||||
|
Text("\(bookmark.readProgress)")
|
||||||
|
.font(.caption2)
|
||||||
|
.bold()
|
||||||
|
Text("%")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.baselineOffset(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(currentState == .archived ? .blue : .orange)
|
|
||||||
|
|
||||||
Button {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
onToggleFavorite(bookmark)
|
Text(bookmark.title)
|
||||||
} label: {
|
.font(.headline)
|
||||||
Label(bookmark.isMarked ? "Remove" : "Favorite",
|
.fontWeight(.semibold)
|
||||||
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
if let publishedDate = formattedPublishedDate {
|
||||||
|
HStack {
|
||||||
|
Label(publishedDate, systemImage: "calendar")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||||
|
Label("\(readingTime) min", systemImage: "clock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if !bookmark.siteName.isEmpty {
|
||||||
|
Label(bookmark.siteName, systemImage: "globe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||||
|
.onTapGesture {
|
||||||
|
SafariUtil.openInSafari(url: bookmark.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.tint(bookmark.isMarked ? .gray : .pink)
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -156,13 +371,10 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
|
||||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
|
|
||||||
guard let date = formatter.date(from: published) else {
|
guard let date = formatter.date(from: published) else {
|
||||||
// Fallback without milliseconds
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
|
||||||
guard let fallbackDate = formatter.date(from: published) else {
|
guard let fallbackDate = formatter.date(from: published) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -173,18 +385,19 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func formatDate(_ date: Date) -> String {
|
private func formatDate(_ date: Date) -> String {
|
||||||
let now = Date()
|
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
// Today
|
// Today
|
||||||
if calendar.isDateInToday(date) {
|
if calendar.isDate(date, inSameDayAs: now) {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.timeStyle = .short
|
formatter.timeStyle = .short
|
||||||
return "Today, \(formatter.string(from: date))"
|
return "Today, \(formatter.string(from: date))"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yesterday
|
// Yesterday
|
||||||
if calendar.isDateInYesterday(date) {
|
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
|
||||||
|
calendar.isDate(date, inSameDayAs: yesterday) {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.timeStyle = .short
|
formatter.timeStyle = .short
|
||||||
return "Yesterday, \(formatter.string(from: date))"
|
return "Yesterday, \(formatter.string(from: date))"
|
||||||
@ -211,13 +424,8 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var imageURL: URL? {
|
private var imageURL: URL? {
|
||||||
// Prioritize image, then thumbnail, then icon
|
|
||||||
if let imageUrl = bookmark.resources.image?.src {
|
if let imageUrl = bookmark.resources.image?.src {
|
||||||
return URL(string: imageUrl)
|
return URL(string: imageUrl)
|
||||||
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
|
|
||||||
return URL(string: thumbnailUrl)
|
|
||||||
} else if let iconUrl = bookmark.resources.icon?.src {
|
|
||||||
return URL(string: iconUrl)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -229,11 +437,9 @@ struct IconBadge: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Image(systemName: systemName)
|
Image(systemName: systemName)
|
||||||
.font(.caption2)
|
.frame(width: 20, height: 20)
|
||||||
.padding(6)
|
.background(color)
|
||||||
.background(color.opacity(0.2))
|
.foregroundColor(.white)
|
||||||
.foregroundColor(color)
|
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4,8 +4,6 @@ import SwiftUI
|
|||||||
|
|
||||||
struct BookmarksView: View {
|
struct BookmarksView: View {
|
||||||
|
|
||||||
@Namespace private var namespace
|
|
||||||
|
|
||||||
// MARK: States
|
// MARK: States
|
||||||
|
|
||||||
@State private var viewModel: BookmarksViewModel
|
@State private var viewModel: BookmarksViewModel
|
||||||
@ -14,7 +12,6 @@ struct BookmarksView: View {
|
|||||||
@State private var showingAddBookmarkFromShare = false
|
@State private var showingAddBookmarkFromShare = false
|
||||||
@State private var shareURL = ""
|
@State private var shareURL = ""
|
||||||
@State private var shareTitle = ""
|
@State private var shareTitle = ""
|
||||||
@State private var bookmarkToDelete: Bookmark? = nil
|
|
||||||
|
|
||||||
let state: BookmarkState
|
let state: BookmarkState
|
||||||
let type: [BookmarkType]
|
let type: [BookmarkType]
|
||||||
@ -39,14 +36,16 @@ struct BookmarksView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if shouldShowCenteredState {
|
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
|
||||||
|
skeletonLoadingView
|
||||||
|
} else if shouldShowCenteredState {
|
||||||
centeredStateView
|
centeredStateView
|
||||||
} else {
|
} else {
|
||||||
bookmarksList
|
bookmarksList
|
||||||
}
|
}
|
||||||
|
|
||||||
// FAB Button - only show for "Unread" and when not in error/loading state
|
// FAB Button - only show for "Unread" and when not in error/loading state
|
||||||
if (state == .unread || state == .all) && !shouldShowCenteredState {
|
if (state == .unread || state == .all) && !shouldShowCenteredState && !viewModel.isInitialLoading {
|
||||||
fabButton
|
fabButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -56,8 +55,7 @@ struct BookmarksView: View {
|
|||||||
set: { selectedBookmarkId = $0 }
|
set: { selectedBookmarkId = $0 }
|
||||||
)
|
)
|
||||||
) { bookmarkId in
|
) { bookmarkId in
|
||||||
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
|
BookmarkDetailView(bookmarkId: bookmarkId)
|
||||||
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddBookmark) {
|
.sheet(isPresented: $showingAddBookmark) {
|
||||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||||
@ -68,18 +66,6 @@ struct BookmarksView: View {
|
|||||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.alert(item: $bookmarkToDelete) { bookmark in
|
|
||||||
Alert(
|
|
||||||
title: Text("Delete Bookmark"),
|
|
||||||
message: Text("Are you sure you want to delete this bookmark? This action cannot be undone."),
|
|
||||||
primaryButton: .destructive(Text("Delete")) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteBookmark(bookmark: bookmark)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||||
@ -179,6 +165,11 @@ struct BookmarksView: View {
|
|||||||
List {
|
List {
|
||||||
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
|
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
|
// Don't navigate to detail if bookmark is pending deletion
|
||||||
|
if viewModel.pendingDeletes[bookmark.id] != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if UIDevice.isPhone {
|
if UIDevice.isPhone {
|
||||||
selectedBookmarkId = bookmark.id
|
selectedBookmarkId = bookmark.id
|
||||||
} else {
|
} else {
|
||||||
@ -195,20 +186,24 @@ struct BookmarksView: View {
|
|||||||
BookmarkCardView(
|
BookmarkCardView(
|
||||||
bookmark: bookmark,
|
bookmark: bookmark,
|
||||||
currentState: state,
|
currentState: state,
|
||||||
|
layout: viewModel.cardLayoutStyle,
|
||||||
|
pendingDelete: viewModel.pendingDeletes[bookmark.id],
|
||||||
onArchive: { bookmark in
|
onArchive: { bookmark in
|
||||||
Task {
|
Task {
|
||||||
await viewModel.toggleArchive(bookmark: bookmark)
|
await viewModel.toggleArchive(bookmark: bookmark)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDelete: { bookmark in
|
onDelete: { bookmark in
|
||||||
bookmarkToDelete = bookmark
|
viewModel.deleteBookmarkWithUndo(bookmark: bookmark)
|
||||||
},
|
},
|
||||||
onToggleFavorite: { bookmark in
|
onToggleFavorite: { bookmark in
|
||||||
Task {
|
Task {
|
||||||
await viewModel.toggleFavorite(bookmark: bookmark)
|
await viewModel.toggleFavorite(bookmark: bookmark)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
namespace: namespace
|
onUndoDelete: { bookmarkId in
|
||||||
|
viewModel.undoDelete(bookmarkId: bookmarkId)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
||||||
@ -219,10 +214,14 @@ struct BookmarksView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
.listRowInsets(EdgeInsets(
|
||||||
|
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||||
|
leading: 16,
|
||||||
|
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||||
|
trailing: 16
|
||||||
|
))
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||||
.matchedTransitionSource(id: bookmark.id, in: namespace)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading indicator for pagination
|
// Show loading indicator for pagination
|
||||||
@ -256,6 +255,25 @@ struct BookmarksView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var skeletonLoadingView: some View {
|
||||||
|
ScrollView {
|
||||||
|
SkeletonLoadingView(layout: viewModel.cardLayoutStyle)
|
||||||
|
.padding(
|
||||||
|
EdgeInsets(
|
||||||
|
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||||
|
leading: 16,
|
||||||
|
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
|
||||||
|
trailing: 16
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refreshBookmarks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var fabButton: some View {
|
private var fabButton: some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
|||||||
@ -7,21 +7,27 @@ class BookmarksViewModel {
|
|||||||
private let getBooksmarksUseCase: PGetBookmarksUseCase
|
private let getBooksmarksUseCase: PGetBookmarksUseCase
|
||||||
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
||||||
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
|
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
|
||||||
|
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||||
|
|
||||||
var bookmarks: BookmarksPage?
|
var bookmarks: BookmarksPage?
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
|
var isInitialLoading = true
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var currentState: BookmarkState = .unread
|
var currentState: BookmarkState = .unread
|
||||||
var currentType = [BookmarkType.article]
|
var currentType = [BookmarkType.article]
|
||||||
var currentTag: String? = nil
|
var currentTag: String? = nil
|
||||||
|
var cardLayoutStyle: CardLayoutStyle = .magazine
|
||||||
|
|
||||||
var showingAddBookmarkFromShare = false
|
var showingAddBookmarkFromShare = false
|
||||||
var shareURL = ""
|
var shareURL = ""
|
||||||
var shareTitle = ""
|
var shareTitle = ""
|
||||||
|
|
||||||
|
// Undo delete functionality
|
||||||
|
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
|
||||||
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var limit = 20
|
private var limit = 50
|
||||||
private var offset = 0
|
private var offset = 0
|
||||||
private var hasMoreData = true
|
private var hasMoreData = true
|
||||||
private var searchWorkItem: DispatchWorkItem?
|
private var searchWorkItem: DispatchWorkItem?
|
||||||
@ -36,13 +42,31 @@ class BookmarksViewModel {
|
|||||||
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
|
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
|
||||||
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||||
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
|
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
|
||||||
|
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||||
|
|
||||||
setupNotificationObserver()
|
setupNotificationObserver()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await loadCardLayout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObserver() {
|
private func setupNotificationObserver() {
|
||||||
|
// Listen for card layout changes
|
||||||
NotificationCenter.default
|
NotificationCenter.default
|
||||||
.publisher(for: NSNotification.Name("AddBookmarkFromShare"))
|
.publisher(for: .cardLayoutChanged)
|
||||||
|
.sink { notification in
|
||||||
|
if let layout = notification.object as? CardLayoutStyle {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.cardLayoutStyle = layout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Listen for
|
||||||
|
NotificationCenter.default
|
||||||
|
.publisher(for: .addBookmarkFromShare)
|
||||||
.sink { [weak self] notification in
|
.sink { [weak self] notification in
|
||||||
self?.handleShareNotification(notification)
|
self?.handleShareNotification(notification)
|
||||||
}
|
}
|
||||||
@ -105,6 +129,7 @@ class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
isInitialLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -168,14 +193,94 @@ class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func deleteBookmark(bookmark: Bookmark) async {
|
func deleteBookmarkWithUndo(bookmark: Bookmark) {
|
||||||
|
// Don't remove from UI immediately - just mark as pending
|
||||||
|
let pendingDelete = PendingDelete(bookmark: bookmark)
|
||||||
|
pendingDeletes[bookmark.id] = pendingDelete
|
||||||
|
|
||||||
|
// Start countdown timer for this specific delete
|
||||||
|
startDeleteCountdown(for: bookmark.id)
|
||||||
|
|
||||||
|
// Schedule actual delete after 3 seconds
|
||||||
|
let deleteTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
|
||||||
|
|
||||||
|
// Check if not cancelled and still pending
|
||||||
|
if !Task.isCancelled, pendingDeletes[bookmark.id] != nil {
|
||||||
|
await executeDelete(bookmark: bookmark)
|
||||||
|
await MainActor.run {
|
||||||
|
// Clean up
|
||||||
|
pendingDeletes[bookmark.id]?.timer?.invalidate()
|
||||||
|
pendingDeletes.removeValue(forKey: bookmark.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the task in the pending delete
|
||||||
|
pendingDeletes[bookmark.id]?.deleteTask = deleteTask
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func undoDelete(bookmarkId: String) {
|
||||||
|
guard let pendingDelete = pendingDeletes[bookmarkId] else { return }
|
||||||
|
|
||||||
|
// Cancel the delete task and timer
|
||||||
|
pendingDelete.deleteTask?.cancel()
|
||||||
|
pendingDelete.timer?.invalidate()
|
||||||
|
|
||||||
|
// Remove from pending deletes
|
||||||
|
pendingDeletes.removeValue(forKey: bookmarkId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startDeleteCountdown(for bookmarkId: String) {
|
||||||
|
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self = self,
|
||||||
|
let pendingDelete = self.pendingDeletes[bookmarkId] else {
|
||||||
|
timer.invalidate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDelete.progress += 1.0 / 30.0 // 3 seconds / 0.1 interval = 30 steps
|
||||||
|
|
||||||
|
if pendingDelete.progress >= 1.0 {
|
||||||
|
timer.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDeletes[bookmarkId]?.timer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
private func executeDelete(bookmark: Bookmark) async {
|
||||||
do {
|
do {
|
||||||
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
|
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
|
||||||
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
|
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Error deleting bookmark"
|
// If delete fails, restore the bookmark
|
||||||
await loadBookmarks(state: currentState)
|
await MainActor.run {
|
||||||
|
errorMessage = "Error deleting bookmark"
|
||||||
|
if var currentBookmarks = bookmarks?.bookmarks {
|
||||||
|
currentBookmarks.insert(bookmark, at: 0)
|
||||||
|
bookmarks?.bookmarks = currentBookmarks
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadCardLayout() async {
|
||||||
|
cardLayoutStyle = await loadCardLayoutUseCase.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PendingDelete: Identifiable, ObservableObject {
|
||||||
|
let id = UUID()
|
||||||
|
let bookmark: Bookmark
|
||||||
|
@Published var progress: Double = 0.0
|
||||||
|
var timer: Timer?
|
||||||
|
var deleteTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(bookmark: Bookmark) {
|
||||||
|
self.bookmark = bookmark
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
readeck/UI/Components/CachedAsyncImage.swift
Normal file
26
readeck/UI/Components/CachedAsyncImage.swift
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct CachedAsyncImage: View {
|
||||||
|
let url: URL?
|
||||||
|
|
||||||
|
init(url: URL?) {
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let url {
|
||||||
|
KFImage(url)
|
||||||
|
.placeholder {
|
||||||
|
Color.gray.opacity(0.3)
|
||||||
|
}
|
||||||
|
.fade(duration: 0.25)
|
||||||
|
.resizable()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Image("placeholder")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Constants {
|
struct Constants {
|
||||||
struct Labels {
|
// Empty for now - can be used for other constants in the future
|
||||||
static let pageSize = 12
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
176
readeck/UI/Components/SkeletonLoadingView.swift
Normal file
176
readeck/UI/Components/SkeletonLoadingView.swift
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SkeletonLoadingView: View {
|
||||||
|
let layout: CardLayoutStyle
|
||||||
|
@State private var animateGradient = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyVStack(spacing: layout == .compact ? 8 : 12) {
|
||||||
|
ForEach(0..<6, id: \.self) { _ in
|
||||||
|
skeletonCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||||
|
animateGradient = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var skeletonCard: some View {
|
||||||
|
switch layout {
|
||||||
|
case .compact:
|
||||||
|
compactSkeletonCard
|
||||||
|
case .magazine:
|
||||||
|
magazineSkeletonCard
|
||||||
|
case .natural:
|
||||||
|
naturalSkeletonCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var compactSkeletonCard: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
// Image placeholder
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
// Title placeholder
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(height: 16)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 180, height: 16)
|
||||||
|
|
||||||
|
// Description placeholder
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(height: 14)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 120, height: 14)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Bottom info placeholder
|
||||||
|
HStack {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 80, height: 12)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 50, height: 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var magazineSkeletonCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Image placeholder
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(height: 140)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
// Title placeholder
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(height: 16)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 200, height: 16)
|
||||||
|
|
||||||
|
// Info placeholders
|
||||||
|
HStack {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 80, height: 12)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 60, height: 12)
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
|
||||||
|
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var naturalSkeletonCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Image placeholder
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(minHeight: 180)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
// Title placeholder
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(height: 16)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 220, height: 16)
|
||||||
|
|
||||||
|
// Info placeholders
|
||||||
|
HStack {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 90, height: 12)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 70, height: 12)
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shimmerGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.gray.opacity(0.3),
|
||||||
|
Color.gray.opacity(0.1),
|
||||||
|
Color.gray.opacity(0.3)
|
||||||
|
],
|
||||||
|
startPoint: animateGradient ? .topLeading : .topTrailing,
|
||||||
|
endPoint: animateGradient ? .bottomTrailing : .bottomLeading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ScrollView {
|
||||||
|
SkeletonLoadingView(layout: .magazine)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,61 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 8
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let result = FlowResult(
|
||||||
|
in: proposal.replacingUnspecifiedDimensions().width,
|
||||||
|
subviews: subviews,
|
||||||
|
spacing: spacing
|
||||||
|
)
|
||||||
|
return result.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
let result = FlowResult(
|
||||||
|
in: bounds.width,
|
||||||
|
subviews: subviews,
|
||||||
|
spacing: spacing
|
||||||
|
)
|
||||||
|
|
||||||
|
for (index, subview) in subviews.enumerated() {
|
||||||
|
subview.place(at: CGPoint(
|
||||||
|
x: bounds.minX + result.frames[index].minX,
|
||||||
|
y: bounds.minY + result.frames[index].minY
|
||||||
|
), proposal: ProposedViewSize(result.frames[index].size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FlowResult {
|
||||||
|
var frames: [CGRect] = []
|
||||||
|
var bounds: CGSize = .zero
|
||||||
|
|
||||||
|
init(in maxWidth: CGFloat, subviews: LayoutSubviews, spacing: CGFloat) {
|
||||||
|
var x: CGFloat = 0
|
||||||
|
var y: CGFloat = 0
|
||||||
|
var lineHeight: CGFloat = 0
|
||||||
|
|
||||||
|
for subview in subviews {
|
||||||
|
let size = subview.sizeThatFits(.unspecified)
|
||||||
|
|
||||||
|
if x + size.width > maxWidth && x > 0 {
|
||||||
|
x = 0
|
||||||
|
y += lineHeight + spacing
|
||||||
|
lineHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
frames.append(CGRect(x: x, y: y, width: size.width, height: size.height))
|
||||||
|
lineHeight = max(lineHeight, size.height)
|
||||||
|
x += size.width + spacing
|
||||||
|
bounds.width = max(bounds.width, x - spacing)
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds.height = y + lineHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum AddBookmarkFieldFocus {
|
enum AddBookmarkFieldFocus {
|
||||||
case url
|
case url
|
||||||
case labels
|
case labels
|
||||||
@ -27,7 +83,6 @@ struct TagManagementView: View {
|
|||||||
let selectedLabelsSet: Set<String>
|
let selectedLabelsSet: Set<String>
|
||||||
let searchText: Binding<String>
|
let searchText: Binding<String>
|
||||||
let isLabelsLoading: Bool
|
let isLabelsLoading: Bool
|
||||||
let availableLabelPages: [[BookmarkLabel]]
|
|
||||||
let filteredLabels: [BookmarkLabel]
|
let filteredLabels: [BookmarkLabel]
|
||||||
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
|
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
|
||||||
|
|
||||||
@ -44,7 +99,6 @@ struct TagManagementView: View {
|
|||||||
selectedLabels: Set<String>,
|
selectedLabels: Set<String>,
|
||||||
searchText: Binding<String>,
|
searchText: Binding<String>,
|
||||||
isLabelsLoading: Bool,
|
isLabelsLoading: Bool,
|
||||||
availableLabelPages: [[BookmarkLabel]],
|
|
||||||
filteredLabels: [BookmarkLabel],
|
filteredLabels: [BookmarkLabel],
|
||||||
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
|
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
|
||||||
onAddCustomTag: @escaping () -> Void,
|
onAddCustomTag: @escaping () -> Void,
|
||||||
@ -55,7 +109,6 @@ struct TagManagementView: View {
|
|||||||
self.selectedLabelsSet = selectedLabels
|
self.selectedLabelsSet = selectedLabels
|
||||||
self.searchText = searchText
|
self.searchText = searchText
|
||||||
self.isLabelsLoading = isLabelsLoading
|
self.isLabelsLoading = isLabelsLoading
|
||||||
self.availableLabelPages = availableLabelPages
|
|
||||||
self.filteredLabels = filteredLabels
|
self.filteredLabels = filteredLabels
|
||||||
self.searchFieldFocus = searchFieldFocus
|
self.searchFieldFocus = searchFieldFocus
|
||||||
self.onAddCustomTag = onAddCustomTag
|
self.onAddCustomTag = onAddCustomTag
|
||||||
@ -138,7 +191,7 @@ struct TagManagementView: View {
|
|||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
} else if availableLabelPages.isEmpty {
|
} else if allLabels.isEmpty {
|
||||||
VStack {
|
VStack {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 24))
|
.font(.system(size: 24))
|
||||||
@ -150,7 +203,7 @@ struct TagManagementView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
} else {
|
} else {
|
||||||
labelsTabView
|
labelsScrollView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@ -158,28 +211,47 @@ struct TagManagementView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var labelsTabView: some View {
|
private var labelsScrollView: some View {
|
||||||
TabView {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
ForEach(chunkedLabels, id: \.self) { rowLabels in
|
||||||
ForEach(labelsPage, id: \.id) { label in
|
HStack(alignment: .top, spacing: 8) {
|
||||||
UnifiedLabelChip(
|
ForEach(rowLabels, id: \.id) { label in
|
||||||
label: label.name,
|
UnifiedLabelChip(
|
||||||
isSelected: selectedLabelsSet.contains(label.name),
|
label: label.name,
|
||||||
isRemovable: false,
|
isSelected: false,
|
||||||
onTap: {
|
isRemovable: false,
|
||||||
onToggleLabel(label.name)
|
onTap: {
|
||||||
}
|
onToggleLabel(label.name)
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never))
|
.frame(height: calculateMaxHeight())
|
||||||
.frame(height: 180)
|
}
|
||||||
.padding(.top, 10)
|
|
||||||
|
private var chunkedLabels: [[BookmarkLabel]] {
|
||||||
|
let maxRows = 3
|
||||||
|
let labelsPerRow = max(1, availableUnselectedLabels.count / maxRows + (availableUnselectedLabels.count % maxRows > 0 ? 1 : 0))
|
||||||
|
return availableUnselectedLabels.chunked(into: labelsPerRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var availableUnselectedLabels: [BookmarkLabel] {
|
||||||
|
let labelsToShow = searchText.wrappedValue.isEmpty ? allLabels : filteredLabels
|
||||||
|
return labelsToShow.filter { !selectedLabelsSet.contains($0.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateMaxHeight() -> CGFloat {
|
||||||
|
// Berechne Höhe für maximal 3 Reihen
|
||||||
|
let rowHeight: CGFloat = 32 // Höhe eines Labels
|
||||||
|
let spacing: CGFloat = 8
|
||||||
|
let maxRows: CGFloat = 3
|
||||||
|
return (rowHeight * maxRows) + (spacing * (maxRows - 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@ -190,11 +262,11 @@ struct TagManagementView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
|
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
FlowLayout(spacing: 8) {
|
||||||
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
|
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
|
||||||
UnifiedLabelChip(
|
UnifiedLabelChip(
|
||||||
label: label,
|
label: label,
|
||||||
isSelected: false,
|
isSelected: true,
|
||||||
isRemovable: true,
|
isRemovable: true,
|
||||||
onTap: {
|
onTap: {
|
||||||
// No action for selected labels
|
// No action for selected labels
|
||||||
@ -210,3 +282,11 @@ struct TagManagementView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Array {
|
||||||
|
func chunked(into size: Int) -> [[Element]] {
|
||||||
|
return stride(from: 0, to: count, by: size).map {
|
||||||
|
Array(self[$0..<Swift.min($0 + size, count)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
68
readeck/UI/Components/UndoToastView.swift
Normal file
68
readeck/UI/Components/UndoToastView.swift
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct UndoToastView: View {
|
||||||
|
let bookmarkTitle: String
|
||||||
|
let progress: Double
|
||||||
|
let onUndo: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Bookmark deleted")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
Text(bookmarkTitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Undo") {
|
||||||
|
onUndo()
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.white.opacity(0.2))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color.black.opacity(0.85))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
// Progress bar at bottom
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView(value: progress)
|
||||||
|
.progressViewStyle(LinearProgressViewStyle(tint: .white.opacity(0.8)))
|
||||||
|
.scaleEffect(y: 0.5)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
UndoToastView(
|
||||||
|
bookmarkTitle: "How to Build Great Products",
|
||||||
|
progress: 0.6,
|
||||||
|
onUndo: {}
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.background(Color.gray.opacity(0.3))
|
||||||
|
}
|
||||||
@ -18,6 +18,8 @@ protocol UseCaseFactory {
|
|||||||
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
||||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
||||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||||
|
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
||||||
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -102,4 +104,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
|
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
|
||||||
return OfflineBookmarkSyncUseCase()
|
return OfflineBookmarkSyncUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
|
||||||
|
return LoadCardLayoutUseCase(settingsRepository: settingsRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||||
|
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,13 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
MockAddTextToSpeechQueueUseCase()
|
MockAddTextToSpeechQueueUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
|
||||||
|
MockLoadCardLayoutUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||||
|
MockSaveCardLayoutUseCase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -204,6 +211,18 @@ class MockOfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockLoadCardLayoutUseCase: PLoadCardLayoutUseCase {
|
||||||
|
func execute() async -> CardLayoutStyle {
|
||||||
|
return .magazine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase {
|
||||||
|
func execute(layout: CardLayoutStyle) async {
|
||||||
|
// Mock implementation - do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Bookmark {
|
extension Bookmark {
|
||||||
static let mock: Bookmark = .init(
|
static let mock: Bookmark = .init(
|
||||||
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
||||||
|
|||||||
@ -61,7 +61,7 @@ struct SearchBookmarksView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in }, namespace: namespace)
|
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in })
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
@ -91,8 +91,7 @@ struct SearchBookmarksView: View {
|
|||||||
set: { selectedBookmarkId = $0 }
|
set: { selectedBookmarkId = $0 }
|
||||||
)
|
)
|
||||||
) { bookmarkId in
|
) { bookmarkId in
|
||||||
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
|
BookmarkDetailView(bookmarkId: bookmarkId)
|
||||||
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if isFirstAppearance {
|
if isFirstAppearance {
|
||||||
|
|||||||
221
readeck/UI/Settings/AppearanceSettingsView.swift
Normal file
221
readeck/UI/Settings/AppearanceSettingsView.swift
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AppearanceSettingsView: View {
|
||||||
|
@State private var selectedCardLayout: CardLayoutStyle = .magazine
|
||||||
|
@State private var selectedTheme: Theme = .system
|
||||||
|
|
||||||
|
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||||
|
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
|
||||||
|
|
||||||
|
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
|
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||||
|
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
SectionHeader(title: "Appearance", icon: "paintbrush")
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
// Theme Section
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Theme")
|
||||||
|
.font(.headline)
|
||||||
|
Picker("Theme", selection: $selectedTheme) {
|
||||||
|
ForEach(Theme.allCases, id: \.self) { theme in
|
||||||
|
Text(theme.displayName).tag(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: selectedTheme) {
|
||||||
|
saveThemeSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Card Layout Section
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Card Layout")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
|
||||||
|
CardLayoutPreview(
|
||||||
|
layout: layout,
|
||||||
|
isSelected: selectedCardLayout == layout
|
||||||
|
) {
|
||||||
|
selectedCardLayout = layout
|
||||||
|
saveCardLayoutSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
loadSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSettings() {
|
||||||
|
// Load theme setting
|
||||||
|
let themeString = UserDefaults.standard.string(forKey: "selectedTheme") ?? "system"
|
||||||
|
selectedTheme = Theme(rawValue: themeString) ?? .system
|
||||||
|
|
||||||
|
// Load card layout setting
|
||||||
|
Task {
|
||||||
|
selectedCardLayout = await loadCardLayoutUseCase.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveThemeSettings() {
|
||||||
|
UserDefaults.standard.set(selectedTheme.rawValue, forKey: "selectedTheme")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveCardLayoutSettings() {
|
||||||
|
Task {
|
||||||
|
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
|
||||||
|
// Notify other parts of the app about the change
|
||||||
|
await MainActor.run {
|
||||||
|
NotificationCenter.default.post(name: .cardLayoutChanged, object: selectedCardLayout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct CardLayoutPreview: View {
|
||||||
|
let layout: CardLayoutStyle
|
||||||
|
let isSelected: Bool
|
||||||
|
let onSelect: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Visual Preview
|
||||||
|
switch layout {
|
||||||
|
case .compact:
|
||||||
|
// Compact: Small image on left, content on right
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 6)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 60)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.4))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 50)
|
||||||
|
|
||||||
|
case .magazine:
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(height: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 5)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 40)
|
||||||
|
|
||||||
|
Text("Fixed 140px")
|
||||||
|
.font(.system(size: 7))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 65)
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||||
|
|
||||||
|
case .natural:
|
||||||
|
VStack(spacing: 3) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.blue.opacity(0.6))
|
||||||
|
.frame(height: 38)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.8))
|
||||||
|
.frame(height: 5)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.primary.opacity(0.6))
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: 35)
|
||||||
|
|
||||||
|
Text("Original ratio")
|
||||||
|
.font(.system(size: 7))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(width: 80, height: 75) // Höher als Magazine
|
||||||
|
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(layout.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text(layout.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
AppearanceSettingsView()
|
||||||
|
.cardStyle()
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
148
readeck/UI/Settings/CacheSettingsView.swift
Normal file
148
readeck/UI/Settings/CacheSettingsView.swift
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct CacheSettingsView: View {
|
||||||
|
@State private var cacheSize: String = "0 MB"
|
||||||
|
@State private var maxCacheSize: Double = 200
|
||||||
|
@State private var isClearing: Bool = false
|
||||||
|
@State private var showClearAlert: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
SectionHeader(title: "Cache Settings", icon: "internaldrive")
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Current Cache Size")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Refresh") {
|
||||||
|
updateCacheSize()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Max Cache Size")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(maxCacheSize)) MB")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
|
||||||
|
Text("Max Cache Size")
|
||||||
|
}
|
||||||
|
.onChange(of: maxCacheSize) { _, newValue in
|
||||||
|
updateMaxCacheSize(newValue)
|
||||||
|
}
|
||||||
|
.accentColor(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showClearAlert = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if isClearing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
.frame(width: 24)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.frame(width: 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Clear Cache")
|
||||||
|
.foregroundColor(isClearing ? .secondary : .red)
|
||||||
|
Text("Remove all cached images")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isClearing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
updateCacheSize()
|
||||||
|
loadMaxCacheSize()
|
||||||
|
}
|
||||||
|
.alert("Clear Cache", isPresented: $showClearAlert) {
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
Button("Clear", role: .destructive) {
|
||||||
|
clearCache()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("This will remove all cached images. They will be downloaded again when needed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCacheSize() {
|
||||||
|
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch result {
|
||||||
|
case .success(let size):
|
||||||
|
let mbSize = Double(size) / (1024 * 1024)
|
||||||
|
self.cacheSize = String(format: "%.1f MB", mbSize)
|
||||||
|
case .failure:
|
||||||
|
self.cacheSize = "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMaxCacheSize() {
|
||||||
|
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
|
||||||
|
if let savedSize = savedSize {
|
||||||
|
maxCacheSize = Double(savedSize) / (1024 * 1024)
|
||||||
|
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = savedSize
|
||||||
|
} else {
|
||||||
|
maxCacheSize = 200
|
||||||
|
let defaultBytes = UInt(200 * 1024 * 1024)
|
||||||
|
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = defaultBytes
|
||||||
|
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateMaxCacheSize(_ newSize: Double) {
|
||||||
|
let bytes = UInt(newSize * 1024 * 1024)
|
||||||
|
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
|
||||||
|
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearCache() {
|
||||||
|
isClearing = true
|
||||||
|
|
||||||
|
KingfisherManager.shared.cache.clearDiskCache {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isClearing = false
|
||||||
|
self.updateCacheSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KingfisherManager.shared.cache.clearMemoryCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CacheSettingsView()
|
||||||
|
.cardStyle()
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@ -15,17 +15,9 @@ struct FontSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(spacing: 20) {
|
||||||
// Header
|
SectionHeader(title: "Font Settings", icon: "textformat")
|
||||||
HStack(spacing: 8) {
|
.padding(.bottom, 4)
|
||||||
Image(systemName: "textformat")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
|
|
||||||
Text("Font")
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Font Family Picker
|
// Font Family Picker
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||||
|
|||||||
@ -21,6 +21,12 @@ struct SettingsContainerView: View {
|
|||||||
FontSettingsView()
|
FontSettingsView()
|
||||||
.cardStyle()
|
.cardStyle()
|
||||||
|
|
||||||
|
AppearanceSettingsView()
|
||||||
|
.cardStyle()
|
||||||
|
|
||||||
|
CacheSettingsView()
|
||||||
|
.cardStyle()
|
||||||
|
|
||||||
SettingsGeneralView()
|
SettingsGeneralView()
|
||||||
.cardStyle()
|
.cardStyle()
|
||||||
|
|
||||||
@ -103,9 +109,19 @@ struct SettingsContainerView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "person.crop.circle")
|
Image(systemName: "person.crop.circle")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("Developer: Ilyas Hallak")
|
HStack(spacing: 4) {
|
||||||
|
Text("Developer:")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button("Ilyas Hallak") {
|
||||||
|
if let url = URL(string: "https://ilyashallak.de") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.blue)
|
||||||
|
.underline()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
|
|||||||
@ -19,23 +19,6 @@ struct SettingsGeneralView: View {
|
|||||||
SectionHeader(title: "General Settings", icon: "gear")
|
SectionHeader(title: "General Settings", icon: "gear")
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
// Theme
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Theme")
|
|
||||||
.font(.headline)
|
|
||||||
Picker("Theme", selection: $viewModel.selectedTheme) {
|
|
||||||
ForEach(Theme.allCases, id: \.self) { theme in
|
|
||||||
Text(theme.displayName).tag(theme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.onChange(of: viewModel.selectedTheme) {
|
|
||||||
Task {
|
|
||||||
await viewModel.saveGeneralSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("General")
|
Text("General")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@ -78,38 +61,6 @@ struct SettingsGeneralView: View {
|
|||||||
.toggleStyle(SwitchToggleStyle())
|
.toggleStyle(SwitchToggleStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data Management
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Data Management")
|
|
||||||
.font(.headline)
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
// await viewModel.clearCache()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text("Clear cache")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
// await viewModel.resetSettings()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text("Reset settings")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
if let successMessage = viewModel.successMessage {
|
if let successMessage = viewModel.successMessage {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@ -52,7 +52,7 @@ class SettingsGeneralViewModel {
|
|||||||
successMessage = "Settings saved"
|
successMessage = "Settings saved"
|
||||||
|
|
||||||
// send notification to apply settings to the app
|
// send notification to apply settings to the app
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SettingsChanged"), object: nil)
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Error saving settings"
|
errorMessage = "Error saving settings"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,16 +141,18 @@ struct SettingsServerView: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
showingLogoutAlert = true
|
showingLogoutAlert = true
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||||
|
.font(.caption)
|
||||||
Text("Logout")
|
Text("Logout")
|
||||||
.fontWeight(.semibold)
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.padding(.horizontal, 16)
|
||||||
.padding()
|
.padding(.vertical, 8)
|
||||||
.background(Color.red)
|
.background(Color(.systemGray5))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.secondary)
|
||||||
.cornerRadius(10)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,7 @@ class SettingsServerViewModel {
|
|||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
successMessage = "Server settings saved and successfully logged in."
|
successMessage = "Server settings saved and successfully logged in."
|
||||||
try await SettingsRepository().saveHasFinishedSetup(true)
|
try await SettingsRepository().saveHasFinishedSetup(true)
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Connection or login failed: \(error.localizedDescription)"
|
errorMessage = "Connection or login failed: \(error.localizedDescription)"
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
@ -80,7 +80,7 @@ class SettingsServerViewModel {
|
|||||||
try await logoutUseCase.execute()
|
try await logoutUseCase.execute()
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
successMessage = "Logged out"
|
successMessage = "Logged out"
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Error logging out"
|
errorMessage = "Error logging out"
|
||||||
}
|
}
|
||||||
|
|||||||
20
readeck/UI/Utils/NotificationNames.swift
Normal file
20
readeck/UI/Utils/NotificationNames.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
// MARK: - App Lifecycle
|
||||||
|
static let settingsChanged = Notification.Name("SettingsChanged")
|
||||||
|
static let setupStatusChanged = Notification.Name("SetupStatusChanged")
|
||||||
|
|
||||||
|
// MARK: - Authentication
|
||||||
|
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
|
||||||
|
|
||||||
|
// MARK: - Network
|
||||||
|
static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable")
|
||||||
|
|
||||||
|
// MARK: - UI Interactions
|
||||||
|
static let dismissKeyboard = Notification.Name("DismissKeyboard")
|
||||||
|
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")
|
||||||
|
|
||||||
|
// MARK: - User Preferences
|
||||||
|
static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
|
||||||
|
}
|
||||||
@ -35,7 +35,7 @@ struct readeckApp: App {
|
|||||||
await loadAppSettings()
|
await loadAppSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SettingsChanged"))) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
|
||||||
Task {
|
Task {
|
||||||
await loadAppSettings()
|
await loadAppSettings()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,7 @@
|
|||||||
<attribute name="src" optional="YES" attributeType="String"/>
|
<attribute name="src" optional="YES" attributeType="String"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="cardLayoutStyle" optional="YES" attributeType="String"/>
|
||||||
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="fontFamily" optional="YES" attributeType="String"/>
|
<attribute name="fontFamily" optional="YES" attributeType="String"/>
|
||||||
<attribute name="fontSize" optional="YES" attributeType="String"/>
|
<attribute name="fontSize" optional="YES" attributeType="String"/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user