Compare commits
No commits in common. "5b520995acf5458ff2f3035562b51957b6699f28" and "953ff5da8d570a2a64a0c184304ce88a739ff7f2" have entirely different histories.
5b520995ac
...
953ff5da8d
3
.gitignore
vendored
3
.gitignore
vendored
@ -63,6 +63,3 @@ fastlane/screenshots/**/*.png
|
|||||||
fastlane/test_output
|
fastlane/test_output
|
||||||
fastlane/.env.default
|
fastlane/.env.default
|
||||||
fastlane/AuthKey_JZJCQWW9N3.p8
|
fastlane/AuthKey_JZJCQWW9N3.p8
|
||||||
|
|
||||||
# Documentation
|
|
||||||
documentation/
|
|
||||||
|
|||||||
@ -48,6 +48,9 @@
|
|||||||
},
|
},
|
||||||
"%lld min" : {
|
"%lld min" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%lld minutes" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld." : {
|
"%lld." : {
|
||||||
|
|
||||||
@ -99,6 +102,12 @@
|
|||||||
},
|
},
|
||||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
|
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Automatic sync" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Automatically mark articles as read" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Available tags" : {
|
"Available tags" : {
|
||||||
|
|
||||||
@ -111,6 +120,9 @@
|
|||||||
},
|
},
|
||||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." : {
|
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Clear cache" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Close" : {
|
"Close" : {
|
||||||
|
|
||||||
@ -120,6 +132,9 @@
|
|||||||
},
|
},
|
||||||
"Critical" : {
|
"Critical" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Data Management" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Debug" : {
|
"Debug" : {
|
||||||
|
|
||||||
@ -258,6 +273,9 @@
|
|||||||
},
|
},
|
||||||
"OK" : {
|
"OK" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Open external links in in-app Safari" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Optional: Custom title" : {
|
"Optional: Custom title" : {
|
||||||
|
|
||||||
@ -301,12 +319,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Reading Settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Remove" : {
|
"Remove" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Reset" : {
|
"Reset" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Reset settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Reset to Defaults" : {
|
"Reset to Defaults" : {
|
||||||
|
|
||||||
@ -316,6 +340,9 @@
|
|||||||
},
|
},
|
||||||
"Resume listening" : {
|
"Resume listening" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Safari Reader Mode" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Save bookmark" : {
|
"Save bookmark" : {
|
||||||
|
|
||||||
@ -364,6 +391,12 @@
|
|||||||
},
|
},
|
||||||
"Speed" : {
|
"Speed" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Sync interval" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Sync Settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Syncing with server..." : {
|
"Syncing with server..." : {
|
||||||
|
|
||||||
|
|||||||
@ -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: .dismissKeyboard, object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -140,6 +140,7 @@ 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: {
|
||||||
|
|||||||
@ -16,11 +16,12 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
|
|
||||||
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// filtered labels based on search text
|
// Computed property for 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: .dismissKeyboard,
|
name: NSNotification.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: .unauthorizedAPIResponse, object: nil)
|
NotificationCenter.default.post(name: NSNotification.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: .unauthorizedAPIResponse, object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
|||||||
@ -9,8 +9,9 @@
|
|||||||
/* 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 */; };
|
||||||
|
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
|
||||||
|
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -66,6 +67,7 @@
|
|||||||
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
5DA242122E17D31A007531C3 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@ -91,7 +93,6 @@
|
|||||||
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 */;
|
||||||
};
|
};
|
||||||
@ -146,7 +147,6 @@
|
|||||||
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 */,
|
||||||
);
|
);
|
||||||
@ -172,6 +172,7 @@
|
|||||||
5D45F9BF2DF858680048D5B8 = {
|
5D45F9BF2DF858680048D5B8 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
|
||||||
5D45F9CA2DF858680048D5B8 /* readeck */,
|
5D45F9CA2DF858680048D5B8 /* readeck */,
|
||||||
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
|
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
|
||||||
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
|
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
|
||||||
@ -239,7 +240,6 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
||||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
||||||
5D9D95482E623668009AF769 /* Kingfisher */,
|
|
||||||
);
|
);
|
||||||
productName = readeck;
|
productName = readeck;
|
||||||
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
||||||
@ -330,7 +330,6 @@
|
|||||||
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 */;
|
||||||
@ -350,6 +349,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -357,6 +357,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -435,7 +436,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -468,7 +469,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -623,7 +624,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -667,7 +668,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -852,14 +853,6 @@
|
|||||||
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";
|
||||||
@ -876,11 +869,6 @@
|
|||||||
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,15 +1,6 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "23641a762ee1f352c85f7c3a1e980d54670907541f34888222e901374fcaa088",
|
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
|
||||||
"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,7 +2,16 @@
|
|||||||
"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: .unauthorizedAPIResponse, object: nil)
|
NotificationCenter.default.post(name: NSNotification.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: .serverDidBecomeAvailable,
|
forName: NSNotification.Name("ServerDidBecomeAvailable"),
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
|
|||||||
@ -12,7 +12,6 @@ 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
|
||||||
@ -32,8 +31,6 @@ 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,10 +79,6 @@ 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 {
|
||||||
@ -122,8 +115,7 @@ 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 {
|
||||||
@ -168,7 +160,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: .setupStatusChanged, object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -182,7 +174,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: .setupStatusChanged, object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -200,7 +192,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: .setupStatusChanged, object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||||
}
|
}
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
}
|
}
|
||||||
@ -214,45 +206,4 @@ 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: .serverDidBecomeAvailable, object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("ServerDidBecomeAvailable"), object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
readeck
|
|
||||||
|
|
||||||
Created by conversion from Localizable.xcstrings
|
|
||||||
*/
|
|
||||||
|
|
||||||
"" = "";
|
|
||||||
"(%lld found)" = "(%lld found)";
|
|
||||||
"%" = "%";
|
|
||||||
"%@ (%lld)" = "%1$@ (%2$lld)";
|
|
||||||
"%lld" = "%lld";
|
|
||||||
"%lld articles in the queue" = "%lld articles in the queue";
|
|
||||||
"%lld bookmark%@ synced successfully" = "%1$lld bookmark%2$@ synced successfully";
|
|
||||||
"%lld bookmark%@ waiting for sync" = "%1$lld bookmark%2$@ waiting for sync";
|
|
||||||
"%lld min" = "%lld min";
|
|
||||||
"%lld." = "%lld.";
|
|
||||||
"%lld/%lld" = "%1$lld/%2$lld";
|
|
||||||
"12 min • Today • example.com" = "12 min • Today • example.com";
|
|
||||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
|
|
||||||
"Add" = "Add";
|
|
||||||
"Add new tag:" = "Add new tag:";
|
|
||||||
"all" = "all";
|
|
||||||
"All tags selected" = "All tags selected";
|
|
||||||
"Archive" = "Archive";
|
|
||||||
"Archive bookmark" = "Archive bookmark";
|
|
||||||
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
|
|
||||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
|
|
||||||
"Available tags" = "Available tags";
|
|
||||||
"Cancel" = "Cancel";
|
|
||||||
"Category-specific Levels" = "Category-specific Levels";
|
|
||||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
|
|
||||||
"Close" = "Close";
|
|
||||||
"Configure log levels and categories" = "Configure log levels and categories";
|
|
||||||
"Critical" = "Critical";
|
|
||||||
"Debug" = "Debug";
|
|
||||||
"DEBUG BUILD" = "DEBUG BUILD";
|
|
||||||
"Debug Settings" = "Debug Settings";
|
|
||||||
"Delete" = "Delete";
|
|
||||||
"Delete Bookmark" = "Delete Bookmark";
|
|
||||||
"Developer: Ilyas Hallak" = "Developer: Ilyas Hallak";
|
|
||||||
"Done" = "Done";
|
|
||||||
"Enter an optional title..." = "Enter an optional title...";
|
|
||||||
"Enter your Readeck server details to get started." = "Enter your Readeck server details to get started.";
|
|
||||||
"Error" = "Error";
|
|
||||||
"Error: %@" = "Error: %@";
|
|
||||||
"Favorite" = "Favorite";
|
|
||||||
"Finished reading?" = "Finished reading?";
|
|
||||||
"Font" = "Font";
|
|
||||||
"Font family" = "Font family";
|
|
||||||
"Font Settings" = "Font Settings";
|
|
||||||
"Font size" = "Font size";
|
|
||||||
"From Bremen with 💚" = "From Bremen with 💚";
|
|
||||||
"General" = "General";
|
|
||||||
"Global Level" = "Global Level";
|
|
||||||
"Global Minimum Level" = "Global Minimum Level";
|
|
||||||
"Global Settings" = "Global Settings";
|
|
||||||
"https://example.com" = "https://example.com";
|
|
||||||
"https://readeck.example.com" = "https://readeck.example.com";
|
|
||||||
"Include Source Location" = "Include Source Location";
|
|
||||||
"Info" = "Info";
|
|
||||||
"Jump to last read position (%lld%%)" = "Jump to last read position (%lld%%)";
|
|
||||||
"Key" = "Key";
|
|
||||||
"Level for %@" = "Level for %@";
|
|
||||||
"Loading %@" = "Loading %@";
|
|
||||||
"Loading article..." = "Loading article...";
|
|
||||||
"Logging Configuration" = "Logging Configuration";
|
|
||||||
"Login & Save" = "Login & Save";
|
|
||||||
"Logout" = "Logout";
|
|
||||||
"Logs below this level will be filtered out globally" = "Logs below this level will be filtered out globally";
|
|
||||||
"Manage Labels" = "Manage Labels";
|
|
||||||
"Mark as favorite" = "Mark as favorite";
|
|
||||||
"More" = "More";
|
|
||||||
"New Bookmark" = "New Bookmark";
|
|
||||||
"No articles in the queue" = "No articles in the queue";
|
|
||||||
"No bookmarks" = "No bookmarks";
|
|
||||||
"No bookmarks found in %@." = "No bookmarks found in %@.";
|
|
||||||
"No bookmarks found." = "No bookmarks found.";
|
|
||||||
"No results" = "No results";
|
|
||||||
"Notice" = "Notice";
|
|
||||||
"OK" = "OK";
|
|
||||||
"Optional: Custom title" = "Optional: Custom title";
|
|
||||||
"Password" = "Password";
|
|
||||||
"Paste" = "Paste";
|
|
||||||
"Please wait while we fetch your bookmarks..." = "Please wait while we fetch your bookmarks...";
|
|
||||||
"Preview" = "Preview";
|
|
||||||
"Progress: %lld%%" = "Progress: %lld%%";
|
|
||||||
"Re-login & Save" = "Re-login & Save";
|
|
||||||
"Read Aloud Feature" = "Read Aloud Feature";
|
|
||||||
"Read article aloud" = "Read article aloud";
|
|
||||||
"Read-aloud Queue" = "Read-aloud Queue";
|
|
||||||
"readeck Bookmark Title" = "readeck Bookmark Title";
|
|
||||||
"Reading %lld/%lld: " = "Reading %1$lld/%2$lld: ";
|
|
||||||
"Remove" = "Remove";
|
|
||||||
"Reset" = "Reset";
|
|
||||||
"Reset to Defaults" = "Reset to Defaults";
|
|
||||||
"Restore" = "Restore";
|
|
||||||
"Resume listening" = "Resume listening";
|
|
||||||
"Save bookmark" = "Save bookmark";
|
|
||||||
"Save Bookmark" = "Save Bookmark";
|
|
||||||
"Saving..." = "Saving...";
|
|
||||||
"Search" = "Search";
|
|
||||||
"Search or add new tag..." = "Search or add new tag...";
|
|
||||||
"Search results" = "Search results";
|
|
||||||
"Search..." = "Search...";
|
|
||||||
"Searching..." = "Searching...";
|
|
||||||
"Select a bookmark or tag" = "Select a bookmark or tag";
|
|
||||||
"Selected tags" = "Selected tags";
|
|
||||||
"Server Endpoint" = "Server Endpoint";
|
|
||||||
"Server not reachable - saving locally" = "Server not reachable - saving locally";
|
|
||||||
"Settings" = "Settings";
|
|
||||||
"Show Performance Logs" = "Show Performance Logs";
|
|
||||||
"Show Timestamps" = "Show Timestamps";
|
|
||||||
"Speed" = "Speed";
|
|
||||||
"Syncing with server..." = "Syncing with server...";
|
|
||||||
"Theme" = "Theme";
|
|
||||||
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.";
|
|
||||||
"Try Again" = "Try Again";
|
|
||||||
"Unable to load bookmarks" = "Unable to load bookmarks";
|
|
||||||
"Unarchive Bookmark" = "Unarchive Bookmark";
|
|
||||||
"URL in clipboard:" = "URL in clipboard:";
|
|
||||||
"Username" = "Username";
|
|
||||||
"Version %@" = "Version %@";
|
|
||||||
"Warning" = "Warning";
|
|
||||||
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
|
|
||||||
"Your Password" = "Your Password";
|
|
||||||
"Your Username" = "Your Username";
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings (German)
|
|
||||||
readeck
|
|
||||||
|
|
||||||
Created by conversion from Localizable.xcstrings
|
|
||||||
*/
|
|
||||||
|
|
||||||
"all" = "Ale";
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
/*
|
|
||||||
Localizable.strings
|
|
||||||
readeck
|
|
||||||
|
|
||||||
Created by conversion from Localizable.xcstrings
|
|
||||||
*/
|
|
||||||
|
|
||||||
"" = "";
|
|
||||||
"(%lld found)" = "(%lld found)";
|
|
||||||
"%" = "%";
|
|
||||||
"%@ (%lld)" = "%1$@ (%2$lld)";
|
|
||||||
"%lld" = "%lld";
|
|
||||||
"%lld articles in the queue" = "%lld articles in the queue";
|
|
||||||
"%lld bookmark%@ synced successfully" = "%1$lld bookmark%2$@ synced successfully";
|
|
||||||
"%lld bookmark%@ waiting for sync" = "%1$lld bookmark%2$@ waiting for sync";
|
|
||||||
"%lld min" = "%lld min";
|
|
||||||
"%lld." = "%lld.";
|
|
||||||
"%lld/%lld" = "%1$lld/%2$lld";
|
|
||||||
"12 min • Today • example.com" = "12 min • Today • example.com";
|
|
||||||
"Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly." = "Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.";
|
|
||||||
"Add" = "Add";
|
|
||||||
"Add new tag:" = "Add new tag:";
|
|
||||||
"all" = "all";
|
|
||||||
"All tags selected" = "All tags selected";
|
|
||||||
"Archive" = "Archive";
|
|
||||||
"Archive bookmark" = "Archive bookmark";
|
|
||||||
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
|
|
||||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
|
|
||||||
"Available tags" = "Available tags";
|
|
||||||
"Cancel" = "Cancel";
|
|
||||||
"Category-specific Levels" = "Category-specific Levels";
|
|
||||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";
|
|
||||||
"Close" = "Close";
|
|
||||||
"Configure log levels and categories" = "Configure log levels and categories";
|
|
||||||
"Critical" = "Critical";
|
|
||||||
"Debug" = "Debug";
|
|
||||||
"DEBUG BUILD" = "DEBUG BUILD";
|
|
||||||
"Debug Settings" = "Debug Settings";
|
|
||||||
"Delete" = "Delete";
|
|
||||||
"Delete Bookmark" = "Delete Bookmark";
|
|
||||||
"Developer: Ilyas Hallak" = "Developer: Ilyas Hallak";
|
|
||||||
"Done" = "Done";
|
|
||||||
"Enter an optional title..." = "Enter an optional title...";
|
|
||||||
"Enter your Readeck server details to get started." = "Enter your Readeck server details to get started.";
|
|
||||||
"Error" = "Error";
|
|
||||||
"Error: %@" = "Error: %@";
|
|
||||||
"Favorite" = "Favorite";
|
|
||||||
"Finished reading?" = "Finished reading?";
|
|
||||||
"Font" = "Font";
|
|
||||||
"Font family" = "Font family";
|
|
||||||
"Font Settings" = "Font Settings";
|
|
||||||
"Font size" = "Font size";
|
|
||||||
"From Bremen with 💚" = "From Bremen with 💚";
|
|
||||||
"General" = "General";
|
|
||||||
"Global Level" = "Global Level";
|
|
||||||
"Global Minimum Level" = "Global Minimum Level";
|
|
||||||
"Global Settings" = "Global Settings";
|
|
||||||
"https://example.com" = "https://example.com";
|
|
||||||
"https://readeck.example.com" = "https://readeck.example.com";
|
|
||||||
"Include Source Location" = "Include Source Location";
|
|
||||||
"Info" = "Info";
|
|
||||||
"Jump to last read position (%lld%%)" = "Jump to last read position (%lld%%)";
|
|
||||||
"Key" = "Key";
|
|
||||||
"Level for %@" = "Level for %@";
|
|
||||||
"Loading %@" = "Loading %@";
|
|
||||||
"Loading article..." = "Loading article...";
|
|
||||||
"Logging Configuration" = "Logging Configuration";
|
|
||||||
"Login & Save" = "Login & Save";
|
|
||||||
"Logout" = "Logout";
|
|
||||||
"Logs below this level will be filtered out globally" = "Logs below this level will be filtered out globally";
|
|
||||||
"Manage Labels" = "Manage Labels";
|
|
||||||
"Mark as favorite" = "Mark as favorite";
|
|
||||||
"More" = "More";
|
|
||||||
"New Bookmark" = "New Bookmark";
|
|
||||||
"No articles in the queue" = "No articles in the queue";
|
|
||||||
"No bookmarks" = "No bookmarks";
|
|
||||||
"No bookmarks found in %@." = "No bookmarks found in %@.";
|
|
||||||
"No bookmarks found." = "No bookmarks found.";
|
|
||||||
"No results" = "No results";
|
|
||||||
"Notice" = "Notice";
|
|
||||||
"OK" = "OK";
|
|
||||||
"Optional: Custom title" = "Optional: Custom title";
|
|
||||||
"Password" = "Password";
|
|
||||||
"Paste" = "Paste";
|
|
||||||
"Please wait while we fetch your bookmarks..." = "Please wait while we fetch your bookmarks...";
|
|
||||||
"Preview" = "Preview";
|
|
||||||
"Progress: %lld%%" = "Progress: %lld%%";
|
|
||||||
"Re-login & Save" = "Re-login & Save";
|
|
||||||
"Read Aloud Feature" = "Read Aloud Feature";
|
|
||||||
"Read article aloud" = "Read article aloud";
|
|
||||||
"Read-aloud Queue" = "Read-aloud Queue";
|
|
||||||
"readeck Bookmark Title" = "readeck Bookmark Title";
|
|
||||||
"Reading %lld/%lld: " = "Reading %1$lld/%2$lld: ";
|
|
||||||
"Remove" = "Remove";
|
|
||||||
"Reset" = "Reset";
|
|
||||||
"Reset to Defaults" = "Reset to Defaults";
|
|
||||||
"Restore" = "Restore";
|
|
||||||
"Resume listening" = "Resume listening";
|
|
||||||
"Save bookmark" = "Save bookmark";
|
|
||||||
"Save Bookmark" = "Save Bookmark";
|
|
||||||
"Saving..." = "Saving...";
|
|
||||||
"Search" = "Search";
|
|
||||||
"Search or add new tag..." = "Search or add new tag...";
|
|
||||||
"Search results" = "Search results";
|
|
||||||
"Search..." = "Search...";
|
|
||||||
"Searching..." = "Searching...";
|
|
||||||
"Select a bookmark or tag" = "Select a bookmark or tag";
|
|
||||||
"Selected tags" = "Selected tags";
|
|
||||||
"Server Endpoint" = "Server Endpoint";
|
|
||||||
"Server not reachable - saving locally" = "Server not reachable - saving locally";
|
|
||||||
"Settings" = "Settings";
|
|
||||||
"Show Performance Logs" = "Show Performance Logs";
|
|
||||||
"Show Timestamps" = "Show Timestamps";
|
|
||||||
"Speed" = "Speed";
|
|
||||||
"Syncing with server..." = "Syncing with server...";
|
|
||||||
"Theme" = "Theme";
|
|
||||||
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." = "This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.";
|
|
||||||
"Try Again" = "Try Again";
|
|
||||||
"Unable to load bookmarks" = "Unable to load bookmarks";
|
|
||||||
"Unarchive Bookmark" = "Unarchive Bookmark";
|
|
||||||
"URL in clipboard:" = "URL in clipboard:";
|
|
||||||
"Username" = "Username";
|
|
||||||
"Version %@" = "Version %@";
|
|
||||||
"Warning" = "Warning";
|
|
||||||
"Your current server connection and login credentials." = "Your current server connection and login credentials.";
|
|
||||||
"Your Password" = "Your Password";
|
|
||||||
"Your Username" = "Your Username";
|
|
||||||
@ -74,9 +74,11 @@ struct AddBookmarkView: View {
|
|||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 20) {
|
||||||
urlField
|
urlField
|
||||||
.id("urlField")
|
.id("urlField")
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 40)
|
||||||
.id("labelsOffset")
|
.id("labelsOffset")
|
||||||
labelsField
|
labelsField
|
||||||
.id("labelsField")
|
.id("labelsField")
|
||||||
@ -158,11 +160,10 @@ struct AddBookmarkView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding()
|
||||||
.background(Color(.systemGray6))
|
.background(Color(.systemGray6))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,6 +183,7 @@ 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,6 +59,19 @@ 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: .unauthorizedAPIResponse,
|
forName: NSNotification.Name("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: .setupStatusChanged,
|
forName: NSNotification.Name("SetupStatusChanged"),
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import Combine
|
|||||||
|
|
||||||
struct BookmarkDetailView: View {
|
struct BookmarkDetailView: View {
|
||||||
let bookmarkId: String
|
let bookmarkId: String
|
||||||
|
let namespace: Namespace.ID?
|
||||||
|
|
||||||
// MARK: - States
|
// MARK: - States
|
||||||
|
|
||||||
@ -23,10 +24,11 @@ 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 = 360
|
private let headerHeight: CGFloat = 320
|
||||||
|
|
||||||
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
|
init(bookmarkId: String, namespace: Namespace.ID? = nil, 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
|
||||||
@ -64,7 +66,7 @@ struct BookmarkDetailView: View {
|
|||||||
})
|
})
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
.cornerRadius(14)
|
.cornerRadius(14)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal)
|
||||||
.animation(.easeInOut, value: webViewHeight)
|
.animation(.easeInOut, value: webViewHeight)
|
||||||
} else if viewModel.isLoadingArticle {
|
} else if viewModel.isLoadingArticle {
|
||||||
ProgressView("Loading article...")
|
ProgressView("Loading article...")
|
||||||
@ -76,7 +78,7 @@ struct BookmarkDetailView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text((URLUtil.extractDomain(from: "open " + viewModel.bookmarkDetail.url) ?? "Open original page"))
|
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -189,11 +191,40 @@ 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) {
|
||||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
|
||||||
.scaledToFit()
|
image
|
||||||
|
.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,6 +61,7 @@ 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,24 +10,46 @@ 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 allLabels.filter { !currentLabels.contains($0.name) }
|
return _availableLabels
|
||||||
}
|
}
|
||||||
|
|
||||||
var filteredLabels: [BookmarkLabel] {
|
var filteredLabels: [BookmarkLabel] {
|
||||||
if searchText.isEmpty {
|
return _filteredLabels
|
||||||
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
|
||||||
|
|
||||||
@ -48,6 +70,8 @@ class BookmarkLabelsViewModel {
|
|||||||
errorMessage = "failed to load labels"
|
errorMessage = "failed to load labels"
|
||||||
showErrorAlert = true
|
showErrorAlert = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
calculatePages()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -119,4 +143,36 @@ 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,7 +17,9 @@ struct ImageViewerView: View {
|
|||||||
Color.black
|
Color.black
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
CachedAsyncImage(url: URL(string: imageUrl))
|
AsyncImage(url: URL(string: imageUrl)) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.scaleEffect(scale)
|
.scaleEffect(scale)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
@ -91,6 +93,12 @@ struct ImageViewerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} placeholder: {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@ -104,4 +112,7 @@ struct ImageViewerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ImageViewerView(imageUrl: "https://example.com/image.jpg")
|
||||||
}
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Foundation
|
|
||||||
import SafariServices
|
import SafariServices
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
@ -13,112 +12,117 @@ 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 onUndoDelete: ((String) -> Void)?
|
let namespace: Namespace.ID?
|
||||||
|
|
||||||
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) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Group {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
switch layout {
|
AsyncImage(url: imageURL) { image in
|
||||||
case .compact:
|
image
|
||||||
compactLayoutView
|
.resizable()
|
||||||
case .magazine:
|
.aspectRatio(contentMode: .fill)
|
||||||
magazineLayoutView
|
.frame(height: 120)
|
||||||
case .natural:
|
} placeholder: {
|
||||||
naturalLayoutView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.opacity(pendingDelete != nil ? 0.4 : 1.0)
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: pendingDelete != nil)
|
|
||||||
|
|
||||||
// Undo toast overlay with progress background
|
Image(R.image.placeholder.name)
|
||||||
if let pendingDelete = pendingDelete {
|
.resizable()
|
||||||
VStack(spacing: 0) {
|
.aspectRatio(contentMode: .fill)
|
||||||
Spacer()
|
.frame(height: 120)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.if(namespace != nil) { view in
|
||||||
|
view.matchedGeometryEffect(id: "image-\(bookmark.id)", in: namespace!)
|
||||||
|
}
|
||||||
|
|
||||||
// Undo button area with circular progress
|
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
||||||
HStack {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
// Circular progress indicator
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(Color.gray.opacity(0.3), lineWidth: 2)
|
.fill(Color(.systemBackground))
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 36, height: 36)
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: CGFloat(pendingDelete.progress))
|
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
|
||||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
.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))
|
.rotationEffect(.degrees(-90))
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 32, height: 32)
|
||||||
.animation(.linear(duration: 0.1), value: pendingDelete.progress)
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||||
}
|
Text("\(bookmark.readProgress)")
|
||||||
|
|
||||||
Text("Deleting...")
|
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.bold()
|
||||||
|
Text("%")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.baselineOffset(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(bookmark.title)
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
|
||||||
|
// Published date
|
||||||
|
if let publishedDate = formattedPublishedDate {
|
||||||
|
HStack {
|
||||||
|
Label(publishedDate, systemImage: "calendar")
|
||||||
Spacer()
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
Button("Undo") {
|
Spacer() // show spacer only if we have the published Date
|
||||||
onUndoDelete?(bookmark.id)
|
|
||||||
}
|
}
|
||||||
.font(.caption.weight(.medium))
|
|
||||||
.foregroundColor(.blue)
|
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||||
.padding(.horizontal, 8)
|
Label("\(readingTime) min", systemImage: "clock")
|
||||||
.padding(.vertical, 3)
|
}
|
||||||
.background(Color.blue.opacity(0.1))
|
}
|
||||||
.clipShape(Capsule())
|
|
||||||
|
HStack {
|
||||||
|
if !bookmark.siteName.isEmpty {
|
||||||
|
Label(bookmark.siteName, systemImage: "globe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
onUndoDelete?(bookmark.id)
|
SafariUtil.openInSafari(url: bookmark.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.bottom, 12)
|
||||||
.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)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
if pendingDelete == nil {
|
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
onDelete(bookmark)
|
onDelete(bookmark)
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
if pendingDelete == nil {
|
// Archive (left)
|
||||||
Button {
|
Button {
|
||||||
onArchive(bookmark)
|
onArchive(bookmark)
|
||||||
} label: {
|
} label: {
|
||||||
@ -139,216 +143,6 @@ struct BookmarkCardView: View {
|
|||||||
.tint(bookmark.isMarked ? .gray : .pink)
|
.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) {
|
|
||||||
ZStack(alignment: .bottomTrailing) {
|
|
||||||
CachedAsyncImage(url: imageURL)
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(height: 140)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
|
|
||||||
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(bookmark.title)
|
|
||||||
.font(.headline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
.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 naturalLayoutView: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
ZStack(alignment: .bottomTrailing) {
|
|
||||||
CachedAsyncImage(url: imageURL)
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(minHeight: 180)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
|
|
||||||
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(bookmark.title)
|
|
||||||
.font(.headline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
.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
|
||||||
|
|
||||||
@ -362,10 +156,13 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'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 {
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
|
// Fallback without milliseconds
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@ -376,19 +173,18 @@ struct BookmarkCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func formatDate(_ date: Date) -> String {
|
private func formatDate(_ date: Date) -> String {
|
||||||
let calendar = Calendar.current
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
// Today
|
// Today
|
||||||
if calendar.isDate(date, inSameDayAs: now) {
|
if calendar.isDateInToday(date) {
|
||||||
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 let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
|
if calendar.isDateInYesterday(date) {
|
||||||
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))"
|
||||||
@ -415,8 +211,13 @@ 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
|
||||||
}
|
}
|
||||||
@ -428,9 +229,11 @@ struct IconBadge: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Image(systemName: systemName)
|
Image(systemName: systemName)
|
||||||
.frame(width: 20, height: 20)
|
.font(.caption2)
|
||||||
.background(color)
|
.padding(6)
|
||||||
.foregroundColor(.white)
|
.background(color.opacity(0.2))
|
||||||
|
.foregroundColor(color)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,8 @@ 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
|
||||||
@ -12,6 +14,7 @@ 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]
|
||||||
@ -36,16 +39,14 @@ struct BookmarksView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
|
if shouldShowCenteredState {
|
||||||
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 && !viewModel.isInitialLoading {
|
if (state == .unread || state == .all) && !shouldShowCenteredState {
|
||||||
fabButton
|
fabButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -55,7 +56,8 @@ struct BookmarksView: View {
|
|||||||
set: { selectedBookmarkId = $0 }
|
set: { selectedBookmarkId = $0 }
|
||||||
)
|
)
|
||||||
) { bookmarkId in
|
) { bookmarkId in
|
||||||
BookmarkDetailView(bookmarkId: bookmarkId)
|
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
|
||||||
|
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddBookmark) {
|
.sheet(isPresented: $showingAddBookmark) {
|
||||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||||
@ -66,6 +68,18 @@ 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)
|
||||||
@ -165,11 +179,6 @@ 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 {
|
||||||
@ -186,24 +195,20 @@ 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
|
||||||
viewModel.deleteBookmarkWithUndo(bookmark: bookmark)
|
bookmarkToDelete = bookmark
|
||||||
},
|
},
|
||||||
onToggleFavorite: { bookmark in
|
onToggleFavorite: { bookmark in
|
||||||
Task {
|
Task {
|
||||||
await viewModel.toggleFavorite(bookmark: bookmark)
|
await viewModel.toggleFavorite(bookmark: bookmark)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUndoDelete: { bookmarkId in
|
namespace: namespace
|
||||||
viewModel.undoDelete(bookmarkId: bookmarkId)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
|
||||||
@ -214,14 +219,10 @@ struct BookmarksView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.listRowInsets(EdgeInsets(
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
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
|
||||||
@ -255,25 +256,6 @@ 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,27 +7,21 @@ 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 = 50
|
private var limit = 20
|
||||||
private var offset = 0
|
private var offset = 0
|
||||||
private var hasMoreData = true
|
private var hasMoreData = true
|
||||||
private var searchWorkItem: DispatchWorkItem?
|
private var searchWorkItem: DispatchWorkItem?
|
||||||
@ -42,31 +36,13 @@ 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: .cardLayoutChanged)
|
.publisher(for: NSNotification.Name("AddBookmarkFromShare"))
|
||||||
.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)
|
||||||
}
|
}
|
||||||
@ -129,7 +105,6 @@ class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
isInitialLoading = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -193,97 +168,14 @@ class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func deleteBookmarkWithUndo(bookmark: Bookmark) {
|
func deleteBookmark(bookmark: Bookmark) async {
|
||||||
// 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
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
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
|
|
||||||
|
|
||||||
// Trigger UI update by modifying the dictionary
|
|
||||||
self.pendingDeletes[bookmarkId] = pendingDelete
|
|
||||||
|
|
||||||
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 {
|
||||||
// If delete fails, restore the bookmark
|
|
||||||
await MainActor.run {
|
|
||||||
errorMessage = "Error deleting bookmark"
|
errorMessage = "Error deleting bookmark"
|
||||||
if var currentBookmarks = bookmarks?.bookmarks {
|
await loadBookmarks(state: currentState)
|
||||||
currentBookmarks.insert(bookmark, at: 0)
|
|
||||||
bookmarks?.bookmarks = currentBookmarks
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadCardLayout() async {
|
|
||||||
cardLayoutStyle = await loadCardLayoutUseCase.execute()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PendingDelete: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let bookmark: Bookmark
|
|
||||||
var progress: Double = 0.0
|
|
||||||
var timer: Timer?
|
|
||||||
var deleteTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init(bookmark: Bookmark) {
|
|
||||||
self.bookmark = bookmark
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
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,5 +12,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Constants {
|
struct Constants {
|
||||||
// Empty for now - can be used for other constants in the future
|
struct Labels {
|
||||||
|
static let pageSize = 12
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,176 +0,0 @@
|
|||||||
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,61 +1,5 @@
|
|||||||
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
|
||||||
@ -83,6 +27,7 @@ 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?
|
||||||
|
|
||||||
@ -99,6 +44,7 @@ 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,
|
||||||
@ -109,6 +55,7 @@ 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
|
||||||
@ -191,7 +138,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 allLabels.isEmpty {
|
} else if availableLabelPages.isEmpty {
|
||||||
VStack {
|
VStack {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 24))
|
.font(.system(size: 24))
|
||||||
@ -203,7 +150,7 @@ struct TagManagementView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
} else {
|
} else {
|
||||||
labelsScrollView
|
labelsTabView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@ -211,47 +158,28 @@ struct TagManagementView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var labelsScrollView: some View {
|
private var labelsTabView: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
TabView {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in
|
||||||
ForEach(chunkedLabels, id: \.self) { rowLabels in
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
ForEach(labelsPage, id: \.id) { label in
|
||||||
ForEach(rowLabels, id: \.id) { label in
|
|
||||||
UnifiedLabelChip(
|
UnifiedLabelChip(
|
||||||
label: label.name,
|
label: label.name,
|
||||||
isSelected: false,
|
isSelected: selectedLabelsSet.contains(label.name),
|
||||||
isRemovable: false,
|
isRemovable: false,
|
||||||
onTap: {
|
onTap: {
|
||||||
onToggleLabel(label.name)
|
onToggleLabel(label.name)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
.frame(height: calculateMaxHeight())
|
|
||||||
}
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never))
|
||||||
private var chunkedLabels: [[BookmarkLabel]] {
|
.frame(height: 180)
|
||||||
let maxRows = 3
|
.padding(.top, 10)
|
||||||
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
|
||||||
@ -262,11 +190,11 @@ struct TagManagementView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
|
|
||||||
FlowLayout(spacing: 8) {
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
||||||
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
|
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
|
||||||
UnifiedLabelChip(
|
UnifiedLabelChip(
|
||||||
label: label,
|
label: label,
|
||||||
isSelected: true,
|
isSelected: false,
|
||||||
isRemovable: true,
|
isRemovable: true,
|
||||||
onTap: {
|
onTap: {
|
||||||
// No action for selected labels
|
// No action for selected labels
|
||||||
@ -282,11 +210,3 @@ 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)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
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,8 +18,6 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -104,12 +102,4 @@ 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,13 +77,6 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
MockAddTextToSpeechQueueUseCase()
|
MockAddTextToSpeechQueueUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
|
|
||||||
MockLoadCardLayoutUseCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
|
||||||
MockSaveCardLayoutUseCase()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -211,18 +204,6 @@ 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 })
|
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in }, namespace: namespace)
|
||||||
}
|
}
|
||||||
.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,7 +91,8 @@ struct SearchBookmarksView: View {
|
|||||||
set: { selectedBookmarkId = $0 }
|
set: { selectedBookmarkId = $0 }
|
||||||
)
|
)
|
||||||
) { bookmarkId in
|
) { bookmarkId in
|
||||||
BookmarkDetailView(bookmarkId: bookmarkId)
|
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
|
||||||
|
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if isFirstAppearance {
|
if isFirstAppearance {
|
||||||
|
|||||||
@ -1,221 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
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,9 +15,17 @@ struct FontSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
SectionHeader(title: "Font Settings", icon: "textformat")
|
// Header
|
||||||
.padding(.bottom, 4)
|
HStack(spacing: 8) {
|
||||||
|
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,12 +21,6 @@ struct SettingsContainerView: View {
|
|||||||
FontSettingsView()
|
FontSettingsView()
|
||||||
.cardStyle()
|
.cardStyle()
|
||||||
|
|
||||||
AppearanceSettingsView()
|
|
||||||
.cardStyle()
|
|
||||||
|
|
||||||
CacheSettingsView()
|
|
||||||
.cardStyle()
|
|
||||||
|
|
||||||
SettingsGeneralView()
|
SettingsGeneralView()
|
||||||
.cardStyle()
|
.cardStyle()
|
||||||
|
|
||||||
@ -109,19 +103,9 @@ struct SettingsContainerView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "person.crop.circle")
|
Image(systemName: "person.crop.circle")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
HStack(spacing: 4) {
|
Text("Developer: Ilyas Hallak")
|
||||||
Text("Developer:")
|
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button("Ilyas Hallak") {
|
|
||||||
if let url = URL(string: "https://ilyashallak.de") {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
.underline()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
|
|||||||
@ -19,6 +19,23 @@ 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)
|
||||||
@ -61,6 +78,38 @@ 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: .settingsChanged, object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("SettingsChanged"), object: nil)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Error saving settings"
|
errorMessage = "Error saving settings"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,18 +141,16 @@ struct SettingsServerView: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
showingLogoutAlert = true
|
showingLogoutAlert = true
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack {
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||||
.font(.caption)
|
|
||||||
Text("Logout")
|
Text("Logout")
|
||||||
.font(.caption)
|
.fontWeight(.semibold)
|
||||||
.fontWeight(.medium)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding()
|
||||||
.background(Color(.systemGray5))
|
.background(Color.red)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.white)
|
||||||
.cornerRadius(8)
|
.cornerRadius(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: .setupStatusChanged, object: nil)
|
NotificationCenter.default.post(name: NSNotification.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: .setupStatusChanged, object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Error logging out"
|
errorMessage = "Error logging out"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
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: .settingsChanged)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SettingsChanged"))) { _ in
|
||||||
Task {
|
Task {
|
||||||
await loadAppSettings()
|
await loadAppSettings()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,6 @@
|
|||||||
<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