feat: Add Kingfisher caching, card layouts, dynamic tag layout, and undo delete

- Integrate Kingfisher for image caching with CachedAsyncImage component
- Add CacheSettingsView for managing image cache size and clearing cache
- Implement three card layout styles: compact, magazine (default), natural
- Add AppearanceSettingsView with visual layout previews and theme settings
- Create Clean Architecture for card layout with domain models and use cases
- Implement FlowLayout for dynamic label width calculation
- Add skeleton loading animation for initial bookmark loads
- Replace delete confirmation dialogs with immediate delete + 3-second undo
- Support multiple simultaneous undo operations with individual progress bars
- Add grayed-out visual feedback for pending deletions
- Centralize notification names in dedicated NotificationNames file
- Remove pagination logic from label management (replaced with FlowLayout)
- Update AsyncImage usage across BookmarkCardView, BookmarkDetailView, ImageViewerView
- Improve UI consistency and spacing throughout the app
This commit is contained in:
Ilyas Hallak 2025-09-04 10:43:27 +02:00
parent 680a9562be
commit df8a7b64b2
43 changed files with 1517 additions and 438 deletions

View File

@ -6,7 +6,7 @@ struct ShareBookmarkView: View {
@FocusState private var focusedField: AddBookmarkFieldFocus?
private func dismissKeyboard() {
NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil)
NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
}
var body: some View {
@ -140,7 +140,6 @@ struct ShareBookmarkView: View {
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
isLabelsLoading: false,
availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages),
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
searchFieldFocus: $focusedField,
onAddCustomTag: {

View File

@ -15,13 +15,12 @@ class ShareBookmarkViewModel: ObservableObject {
let extensionContext: NSExtensionContext?
private let logger = Logger.viewModel
// Computed properties for pagination
var availableLabels: [BookmarkLabelDto] {
return labels.filter { !selectedLabels.contains($0.name) }
}
// Computed property for filtered labels based on search text
// filtered labels based on search text
var filteredLabels: [BookmarkLabelDto] {
if searchText.isEmpty {
return availableLabels

View File

@ -34,7 +34,7 @@ class ShareViewController: UIViewController {
NotificationCenter.default.addObserver(
self,
selector: #selector(dismissKeyboard),
name: NSNotification.Name("DismissKeyboard"),
name: .dismissKeyboard,
object: nil
)
}

View File

@ -41,7 +41,7 @@ class SimpleAPI {
guard 200...299 ~= httpResponse.statusCode else {
if httpResponse.statusCode == 401 {
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
}
}
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
@ -94,7 +94,7 @@ class SimpleAPI {
guard 200...299 ~= httpResponse.statusCode else {
if httpResponse.statusCode == 401 {
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
}
}
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"

View File

@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
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 */; };
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
/* End PBXBuildFile section */
@ -90,6 +91,7 @@
UI/Components/CustomTextFieldStyle.swift,
UI/Components/TagManagementView.swift,
UI/Components/UnifiedLabelChip.swift,
UI/Utils/NotificationNames.swift,
);
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
};
@ -144,6 +146,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
);
@ -236,6 +239,7 @@
packageProductDependencies = (
5D348CC22E0C9F4F00D0AF21 /* netfox */,
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
5D9D95482E623668009AF769 /* Kingfisher */,
);
productName = readeck;
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
@ -326,6 +330,7 @@
packageReferences = (
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
@ -847,6 +852,14 @@
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" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
@ -863,6 +876,11 @@
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
productName = netfox;
};
5D9D95482E623668009AF769 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;

View File

@ -1,6 +1,15 @@
{
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
"originHash" : "23641a762ee1f352c85f7c3a1e980d54670907541f34888222e901374fcaa088",
"pins" : [
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher.git",
"state" : {
"revision" : "2015fda791daa72c8058619545a593bf8c1dd59f",
"version" : "8.5.0"
}
},
{
"identity" : "netfox",
"kind" : "remoteSourceControl",

View File

@ -2,16 +2,7 @@
"images" : [
{
"filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
"idiom" : "universal"
}
],
"info" : {

View File

@ -45,7 +45,7 @@ class API: PAPI {
private func handleUnauthorizedResponse(_ statusCode: Int) {
if statusCode == 401 {
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
}
}
}

View File

@ -119,7 +119,7 @@ class OfflineSyncManager: ObservableObject {
func startAutoSync() {
// Monitor server connectivity and auto-sync when server becomes reachable
NotificationCenter.default.addObserver(
forName: NSNotification.Name("ServerDidBecomeAvailable"),
forName: .serverDidBecomeAvailable,
object: nil,
queue: .main
) { [weak self] _ in

View File

@ -12,6 +12,7 @@ struct Settings {
var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
@ -31,6 +32,8 @@ protocol PSettingsRepository {
func savePassword(_ password: String) async throws
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) 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 }
}
@ -79,6 +82,10 @@ class SettingsRepository: PSettingsRepository {
existingSettings.theme = theme.rawValue
}
if let cardLayoutStyle = settings.cardLayoutStyle {
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
}
try context.save()
continuation.resume()
} catch {
@ -115,7 +122,8 @@ class SettingsRepository: PSettingsRepository {
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
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)
} catch {
@ -160,7 +168,7 @@ class SettingsRepository: PSettingsRepository {
self.hasFinishedSetup = true
// Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
}
}
}
@ -174,7 +182,7 @@ class SettingsRepository: PSettingsRepository {
if !token.isEmpty {
self.hasFinishedSetup = true
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
}
}
}
@ -192,7 +200,7 @@ class SettingsRepository: PSettingsRepository {
self.hasFinishedSetup = hasFinishedSetup
// Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
}
continuation.resume()
}
@ -206,4 +214,45 @@ class SettingsRepository: PSettingsRepository {
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)
}
}
}
}
}

View File

@ -25,7 +25,7 @@ class ServerConnectivity: ObservableObject {
// Notify when server becomes available
if !wasReachable && serverReachable {
NotificationCenter.default.post(name: NSNotification.Name("ServerDidBecomeAvailable"), object: nil)
NotificationCenter.default.post(name: .serverDidBecomeAvailable, object: nil)
}
}
}

View File

@ -0,0 +1,29 @@
import Foundation
enum CardLayoutStyle: String, CaseIterable, Codable {
case compact = "compact"
case magazine = "magazine"
case natural = "natural"
var displayName: String {
switch self {
case .compact:
return "Compact"
case .magazine:
return "Magazine"
case .natural:
return "Natural"
}
}
var description: String {
switch self {
case .compact:
return "Small thumbnails with content focus"
case .magazine:
return "Fixed height headers for consistent layout"
case .natural:
return "Images in original aspect ratio"
}
}
}

View File

@ -0,0 +1,21 @@
import Foundation
protocol PLoadCardLayoutUseCase {
func execute() async -> CardLayoutStyle
}
class LoadCardLayoutUseCase: PLoadCardLayoutUseCase {
private let settingsRepository: PSettingsRepository
init(settingsRepository: PSettingsRepository) {
self.settingsRepository = settingsRepository
}
func execute() async -> CardLayoutStyle {
do {
return try await settingsRepository.loadCardLayoutStyle()
} catch {
return .magazine
}
}
}

View File

@ -0,0 +1,22 @@
import Foundation
protocol PSaveCardLayoutUseCase {
func execute(layout: CardLayoutStyle) async
}
class SaveCardLayoutUseCase: PSaveCardLayoutUseCase {
private let settingsRepository: PSettingsRepository
private let logger = Logger.data
init(settingsRepository: PSettingsRepository) {
self.settingsRepository = settingsRepository
}
func execute(layout: CardLayoutStyle) async {
do {
try await settingsRepository.saveCardLayoutStyle(layout)
} catch {
logger.error("Failed to save card layout style: \(error)")
}
}
}

View File

@ -74,11 +74,9 @@ struct AddBookmarkView: View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 20) {
VStack(spacing: 20) {
VStack(spacing: 16) {
urlField
.id("urlField")
Spacer()
.frame(height: 40)
.id("labelsOffset")
labelsField
.id("labelsField")
@ -160,10 +158,11 @@ struct AddBookmarkView: View {
}
}
}
.padding()
.padding(12)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: 8))
.transition(.opacity.combined(with: .move(edge: .top)))
.padding(.top, 4)
}
}
@ -183,7 +182,6 @@ struct AddBookmarkView: View {
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
isLabelsLoading: viewModel.isLabelsLoading,
availableLabelPages: viewModel.availableLabelPages,
filteredLabels: viewModel.filteredLabels,
searchFieldFocus: $focusedField,
onAddCustomTag: {

View File

@ -59,19 +59,6 @@ class AddBookmarkViewModel {
}
}
var availableLabelPages: [[BookmarkLabel]] {
let pageSize = Constants.Labels.pageSize
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
if labelsToShow.count <= pageSize {
return [labelsToShow]
} else {
return stride(from: 0, to: labelsToShow.count, by: pageSize).map {
Array(labelsToShow[$0..<min($0 + pageSize, labelsToShow.count)])
}
}
}
// MARK: - Labels Management
@MainActor

View File

@ -25,7 +25,7 @@ class AppViewModel: ObservableObject {
private func setupNotificationObservers() {
NotificationCenter.default.addObserver(
forName: NSNotification.Name("UnauthorizedAPIResponse"),
forName: .unauthorizedAPIResponse,
object: nil,
queue: .main
) { [weak self] _ in
@ -35,7 +35,7 @@ class AppViewModel: ObservableObject {
}
NotificationCenter.default.addObserver(
forName: NSNotification.Name("SetupStatusChanged"),
forName: .setupStatusChanged,
object: nil,
queue: .main
) { [weak self] _ in

View File

@ -4,7 +4,6 @@ import Combine
struct BookmarkDetailView: View {
let bookmarkId: String
let namespace: Namespace.ID?
// MARK: - States
@ -24,11 +23,10 @@ struct BookmarkDetailView: View {
@EnvironmentObject var appSettings: AppSettings
@Environment(\.dismiss) private var dismiss
private let headerHeight: CGFloat = 320
private let headerHeight: CGFloat = 360
init(bookmarkId: String, namespace: Namespace.ID? = nil, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
self.bookmarkId = bookmarkId
self.namespace = namespace
self.viewModel = viewModel
self.webViewHeight = webViewHeight
self.showingFontSettings = showingFontSettings
@ -66,7 +64,7 @@ struct BookmarkDetailView: View {
})
.frame(height: webViewHeight)
.cornerRadius(14)
.padding(.horizontal)
.padding(.horizontal, 4)
.animation(.easeInOut, value: webViewHeight)
} else if viewModel.isLoadingArticle {
ProgressView("Loading article...")
@ -191,40 +189,11 @@ struct BookmarkDetailView: View {
GeometryReader { geo in
let offset = geo.frame(in: .global).minY
ZStack(alignment: .top) {
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
image
.resizable()
.scaledToFill()
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.scaledToFit()
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
.clipped()
.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))
.offset(y: (offset > 0 ? -offset : 0))
// Tap area and zoom icon
VStack {

View File

@ -61,7 +61,6 @@ struct BookmarkLabelsView: View {
selectedLabels: Set(viewModel.currentLabels),
searchText: $viewModel.searchText,
isLabelsLoading: viewModel.isInitialLoading,
availableLabelPages: viewModel.availableLabelPages,
filteredLabels: viewModel.filteredLabels,
onAddCustomTag: {
Task {

View File

@ -10,46 +10,24 @@ class BookmarkLabelsViewModel {
var isInitialLoading = false
var errorMessage: String?
var showErrorAlert = false
var currentLabels: [String] = [] {
didSet {
if oldValue != currentLabels {
calculatePages()
}
}
}
var currentLabels: [String] = []
var newLabelText = ""
var searchText = "" {
didSet {
if oldValue != searchText {
calculatePages()
}
}
}
var searchText = ""
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 allLabels: [BookmarkLabel] = []
var availableLabels: [BookmarkLabel] {
return _availableLabels
return allLabels.filter { !currentLabels.contains($0.name) }
}
var filteredLabels: [BookmarkLabel] {
return _filteredLabels
if searchText.isEmpty {
return availableLabels
} else {
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
var availableLabelPages: [[BookmarkLabel]] = []
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
self.currentLabels = initialLabels
@ -70,8 +48,6 @@ class BookmarkLabelsViewModel {
errorMessage = "failed to load labels"
showErrorAlert = true
}
calculatePages()
}
@MainActor
@ -143,36 +119,4 @@ class BookmarkLabelsViewModel {
func updateLabels(_ labels: [String]) {
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)])
}
}
}
}

View File

@ -17,102 +17,91 @@ struct ImageViewerView: View {
Color.black
.ignoresSafeArea()
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.scaledToFit()
.scaleEffect(scale)
.offset(offset)
.offset(dragOffset)
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
scale = min(max(scale * delta, 1), 4)
}
.onEnded { _ in
lastScale = 1.0
if scale < 1 {
withAnimation(.spring()) {
scale = 1
offset = .zero
}
}
if scale > 4 {
scale = 4
}
},
DragGesture()
.onChanged { value in
if scale > 1 {
let newOffset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
offset = newOffset
} else {
// Dismiss gesture when not zoomed
dragOffset = value.translation
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
if dragDistance > 50 {
isDraggingToDismiss = true
}
}
}
.onEnded { value in
if scale <= 1 {
lastOffset = offset
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
if dragDistance > 100 || velocity > 500 {
dismiss()
} else {
withAnimation(.spring()) {
dragOffset = .zero
isDraggingToDismiss = false
}
}
} else {
lastOffset = offset
}
}
)
)
.onTapGesture(count: 2) {
withAnimation(.spring()) {
if scale > 1 {
scale = 1
offset = .zero
lastOffset = .zero
} else {
scale = 2
CachedAsyncImage(url: URL(string: imageUrl))
.scaledToFit()
.scaleEffect(scale)
.offset(offset)
.offset(dragOffset)
.opacity(isDraggingToDismiss ? 0.8 : 1.0)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
scale = min(max(scale * delta, 1), 4)
}
.onEnded { _ in
lastScale = 1.0
if scale < 1 {
withAnimation(.spring()) {
scale = 1
offset = .zero
}
}
if scale > 4 {
scale = 4
}
},
DragGesture()
.onChanged { value in
if scale > 1 {
let newOffset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
offset = newOffset
} else {
// Dismiss gesture when not zoomed
dragOffset = value.translation
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
if dragDistance > 50 {
isDraggingToDismiss = true
}
}
}
.onEnded { value in
if scale <= 1 {
lastOffset = offset
let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2))
let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2))
if dragDistance > 100 || velocity > 500 {
dismiss()
} else {
withAnimation(.spring()) {
dragOffset = .zero
isDraggingToDismiss = false
}
}
} else {
lastOffset = offset
}
}
)
)
.onTapGesture(count: 2) {
withAnimation(.spring()) {
if scale > 1 {
scale = 1
offset = .zero
lastOffset = .zero
} else {
scale = 2
}
}
} placeholder: {
ProgressView()
.scaleEffect(1.5)
.foregroundColor(.white)
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
.foregroundColor(.white)
}
.foregroundColor(.white)
}
}
}
}
}
#Preview {
ImageViewerView(imageUrl: "https://example.com/image.jpg")
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import Foundation
import SafariServices
extension View {
@ -12,35 +13,200 @@ extension View {
}
struct BookmarkCardView: View {
@Environment(\.colorScheme) var colorScheme
let bookmark: Bookmark
let currentState: BookmarkState
let layout: CardLayoutStyle
let pendingDelete: PendingDelete?
let onArchive: (Bookmark) -> Void
let onDelete: (Bookmark) -> Void
let onToggleFavorite: (Bookmark) -> Void
let namespace: Namespace.ID?
let onUndoDelete: ((String) -> Void)?
init(
bookmark: Bookmark,
currentState: BookmarkState,
layout: CardLayoutStyle = .magazine,
pendingDelete: PendingDelete? = nil,
onArchive: @escaping (Bookmark) -> Void,
onDelete: @escaping (Bookmark) -> Void,
onToggleFavorite: @escaping (Bookmark) -> Void,
onUndoDelete: ((String) -> Void)? = nil
) {
self.bookmark = bookmark
self.currentState = currentState
self.layout = layout
self.pendingDelete = pendingDelete
self.onArchive = onArchive
self.onDelete = onDelete
self.onToggleFavorite = onToggleFavorite
self.onUndoDelete = onUndoDelete
}
var body: some View {
ZStack(alignment: .bottom) {
Group {
switch layout {
case .compact:
compactLayoutView
case .magazine:
magazineLayoutView
case .natural:
naturalLayoutView
}
}
.opacity(pendingDelete != nil ? 0.4 : 1.0)
.animation(.easeInOut(duration: 0.2), value: pendingDelete != nil)
// Undo button overlay
if let pendingDelete = pendingDelete {
VStack(spacing: 0) {
Spacer()
// Undo button area - only when user interacts
HStack {
HStack(spacing: 6) {
Image(systemName: "trash")
.foregroundColor(.secondary)
.font(.caption2)
Text("Deleting...")
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
Button("Undo") {
onUndoDelete?(bookmark.id)
}
.font(.caption.weight(.medium))
.foregroundColor(.blue)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
.onTapGesture {
onUndoDelete?(bookmark.id)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemBackground).opacity(0.95))
}
.clipShape(RoundedRectangle(cornerRadius: layout == .compact ? 8 : 12))
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// Progress Bar am unteren Rand
if let pendingDelete = pendingDelete {
VStack(spacing: 0) {
Spacer()
// Progress Bar
ProgressView(value: pendingDelete.progress, total: 1.0)
.progressViewStyle(LinearProgressViewStyle(tint: .red))
.scaleEffect(x: 1, y: 1.5, anchor: .center)
.clipShape(
.rect(
bottomLeadingRadius: layout == .compact ? 8 : 12,
bottomTrailingRadius: layout == .compact ? 8 : 12
)
)
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if pendingDelete == nil {
Button("Delete", role: .destructive) {
onDelete(bookmark)
}
.tint(.red)
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if pendingDelete == nil {
Button {
onArchive(bookmark)
} label: {
if currentState == .archived {
Label("Restore", systemImage: "tray.and.arrow.up")
} else {
Label("Archive", systemImage: "archivebox")
}
}
.tint(currentState == .archived ? .blue : .orange)
Button {
onToggleFavorite(bookmark)
} label: {
Label(bookmark.isMarked ? "Remove" : "Favorite",
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
}
.tint(bookmark.isMarked ? .gray : .pink)
}
}
}
private var compactLayoutView: some View {
HStack(alignment: .top, spacing: 12) {
CachedAsyncImage(url: imageURL)
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
Text(bookmark.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
if !bookmark.description.isEmpty {
Text(bookmark.description)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
}
HStack(spacing: 4) {
if !bookmark.siteName.isEmpty {
HStack(spacing: 2) {
Image(systemName: "globe")
Text(bookmark.siteName)
}
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if let readingTime = bookmark.readingTime, readingTime > 0 {
HStack(spacing: 2) {
Image(systemName: "clock")
Text("\(readingTime) min")
}
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.padding(12)
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var magazineLayoutView: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .bottomTrailing) {
AsyncImage(url: imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 120)
} placeholder: {
Image(R.image.placeholder.name)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 120)
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.if(namespace != nil) { view in
view.matchedGeometryEffect(id: "image-\(bookmark.id)", in: namespace!)
}
CachedAsyncImage(url: imageURL)
.aspectRatio(contentMode: .fill)
.frame(height: 140)
.clipShape(RoundedRectangle(cornerRadius: 8))
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
ZStack {
@ -77,15 +243,12 @@ struct BookmarkCardView: View {
VStack(alignment: .leading, spacing: 4) {
HStack {
// Published date
if let publishedDate = formattedPublishedDate {
HStack {
Label(publishedDate, systemImage: "calendar")
Spacer()
}
Spacer() // show spacer only if we have the published Date
Spacer()
}
if let readingTime = bookmark.readingTime, readingTime > 0 {
@ -107,41 +270,93 @@ struct BookmarkCardView: View {
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.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) {
Button("Delete", role: .destructive) {
onDelete(bookmark)
}
.tint(.red)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
// Archive (left)
Button {
onArchive(bookmark)
} label: {
if currentState == .archived {
Label("Restore", systemImage: "tray.and.arrow.up")
} else {
Label("Archive", systemImage: "archivebox")
.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)
}
}
.tint(currentState == .archived ? .blue : .orange)
Button {
onToggleFavorite(bookmark)
} label: {
Label(bookmark.isMarked ? "Remove" : "Favorite",
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
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)
}
.tint(bookmark.isMarked ? .gray : .pink)
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
}
// MARK: - Computed Properties
@ -156,13 +371,10 @@ struct BookmarkCardView: View {
}
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
guard let date = formatter.date(from: published) else {
// Fallback without milliseconds
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
guard let fallbackDate = formatter.date(from: published) else {
return nil
}
@ -173,18 +385,19 @@ struct BookmarkCardView: View {
}
private func formatDate(_ date: Date) -> String {
let now = Date()
let calendar = Calendar.current
let now = Date()
// Today
if calendar.isDateInToday(date) {
if calendar.isDate(date, inSameDayAs: now) {
let formatter = DateFormatter()
formatter.timeStyle = .short
return "Today, \(formatter.string(from: date))"
}
// Yesterday
if calendar.isDateInYesterday(date) {
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
calendar.isDate(date, inSameDayAs: yesterday) {
let formatter = DateFormatter()
formatter.timeStyle = .short
return "Yesterday, \(formatter.string(from: date))"
@ -211,13 +424,8 @@ struct BookmarkCardView: View {
}
private var imageURL: URL? {
// Prioritize image, then thumbnail, then icon
if let imageUrl = bookmark.resources.image?.src {
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
}
@ -229,11 +437,9 @@ struct IconBadge: View {
var body: some View {
Image(systemName: systemName)
.font(.caption2)
.padding(6)
.background(color.opacity(0.2))
.foregroundColor(color)
.frame(width: 20, height: 20)
.background(color)
.foregroundColor(.white)
.clipShape(Circle())
}
}
}

View File

@ -4,8 +4,6 @@ import SwiftUI
struct BookmarksView: View {
@Namespace private var namespace
// MARK: States
@State private var viewModel: BookmarksViewModel
@ -14,7 +12,6 @@ struct BookmarksView: View {
@State private var showingAddBookmarkFromShare = false
@State private var shareURL = ""
@State private var shareTitle = ""
@State private var bookmarkToDelete: Bookmark? = nil
let state: BookmarkState
let type: [BookmarkType]
@ -39,14 +36,16 @@ struct BookmarksView: View {
var body: some View {
ZStack {
if shouldShowCenteredState {
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
skeletonLoadingView
} else if shouldShowCenteredState {
centeredStateView
} else {
bookmarksList
}
// FAB Button - only show for "Unread" and when not in error/loading state
if (state == .unread || state == .all) && !shouldShowCenteredState {
if (state == .unread || state == .all) && !shouldShowCenteredState && !viewModel.isInitialLoading {
fabButton
}
}
@ -56,8 +55,7 @@ struct BookmarksView: View {
set: { selectedBookmarkId = $0 }
)
) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
BookmarkDetailView(bookmarkId: bookmarkId)
}
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
@ -68,18 +66,6 @@ struct BookmarksView: View {
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 {
Task {
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
@ -179,6 +165,11 @@ struct BookmarksView: View {
List {
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
Button(action: {
// Don't navigate to detail if bookmark is pending deletion
if viewModel.pendingDeletes[bookmark.id] != nil {
return
}
if UIDevice.isPhone {
selectedBookmarkId = bookmark.id
} else {
@ -195,20 +186,24 @@ struct BookmarksView: View {
BookmarkCardView(
bookmark: bookmark,
currentState: state,
layout: viewModel.cardLayoutStyle,
pendingDelete: viewModel.pendingDeletes[bookmark.id],
onArchive: { bookmark in
Task {
await viewModel.toggleArchive(bookmark: bookmark)
}
},
onDelete: { bookmark in
bookmarkToDelete = bookmark
viewModel.deleteBookmarkWithUndo(bookmark: bookmark)
},
onToggleFavorite: { bookmark in
Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
},
namespace: namespace
onUndoDelete: { bookmarkId in
viewModel.undoDelete(bookmarkId: bookmarkId)
}
)
.onAppear {
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
@ -219,10 +214,14 @@ struct BookmarksView: View {
}
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowInsets(EdgeInsets(
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
leading: 16,
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
trailing: 16
))
.listRowSeparator(.hidden)
.listRowBackground(Color(R.color.bookmark_list_bg))
.matchedTransitionSource(id: bookmark.id, in: namespace)
}
// Show loading indicator for pagination
@ -256,6 +255,25 @@ struct BookmarksView: View {
}
}
@ViewBuilder
private var skeletonLoadingView: some View {
ScrollView {
SkeletonLoadingView(layout: viewModel.cardLayoutStyle)
.padding(
EdgeInsets(
top: viewModel.cardLayoutStyle == .compact ? 8 : 12,
leading: 16,
bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12,
trailing: 16
)
)
}
.background(Color(R.color.bookmark_list_bg))
.refreshable {
await viewModel.refreshBookmarks()
}
}
@ViewBuilder
private var fabButton: some View {
VStack {

View File

@ -7,21 +7,27 @@ class BookmarksViewModel {
private let getBooksmarksUseCase: PGetBookmarksUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
var bookmarks: BookmarksPage?
var isLoading = false
var isInitialLoading = true
var errorMessage: String?
var currentState: BookmarkState = .unread
var currentType = [BookmarkType.article]
var currentTag: String? = nil
var cardLayoutStyle: CardLayoutStyle = .magazine
var showingAddBookmarkFromShare = false
var shareURL = ""
var shareTitle = ""
// Undo delete functionality
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
private var cancellables = Set<AnyCancellable>()
private var limit = 20
private var limit = 50
private var offset = 0
private var hasMoreData = true
private var searchWorkItem: DispatchWorkItem?
@ -36,13 +42,31 @@ class BookmarksViewModel {
getBooksmarksUseCase = factory.makeGetBookmarksUseCase()
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
setupNotificationObserver()
Task {
await loadCardLayout()
}
}
private func setupNotificationObserver() {
// Listen for card layout changes
NotificationCenter.default
.publisher(for: NSNotification.Name("AddBookmarkFromShare"))
.publisher(for: .cardLayoutChanged)
.sink { notification in
if let layout = notification.object as? CardLayoutStyle {
Task { @MainActor in
self.cardLayoutStyle = layout
}
}
}
.store(in: &cancellables)
// Listen for
NotificationCenter.default
.publisher(for: .addBookmarkFromShare)
.sink { [weak self] notification in
self?.handleShareNotification(notification)
}
@ -105,6 +129,7 @@ class BookmarksViewModel {
}
isLoading = false
isInitialLoading = false
}
@MainActor
@ -168,14 +193,94 @@ class BookmarksViewModel {
}
@MainActor
func deleteBookmark(bookmark: Bookmark) async {
func deleteBookmarkWithUndo(bookmark: Bookmark) {
// Don't remove from UI immediately - just mark as pending
let pendingDelete = PendingDelete(bookmark: bookmark)
pendingDeletes[bookmark.id] = pendingDelete
// Start countdown timer for this specific delete
startDeleteCountdown(for: bookmark.id)
// Schedule actual delete after 3 seconds
let deleteTask = Task {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
// Check if not cancelled and still pending
if !Task.isCancelled, pendingDeletes[bookmark.id] != nil {
await executeDelete(bookmark: bookmark)
await MainActor.run {
// Clean up
pendingDeletes[bookmark.id]?.timer?.invalidate()
pendingDeletes.removeValue(forKey: bookmark.id)
}
}
}
// Store the task in the pending delete
pendingDeletes[bookmark.id]?.deleteTask = deleteTask
}
@MainActor
func undoDelete(bookmarkId: String) {
guard let pendingDelete = pendingDeletes[bookmarkId] else { return }
// Cancel the delete task and timer
pendingDelete.deleteTask?.cancel()
pendingDelete.timer?.invalidate()
// Remove from pending deletes
pendingDeletes.removeValue(forKey: bookmarkId)
}
private func startDeleteCountdown(for bookmarkId: String) {
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
Task { @MainActor in
guard let self = self,
let pendingDelete = self.pendingDeletes[bookmarkId] else {
timer.invalidate()
return
}
pendingDelete.progress += 1.0 / 30.0 // 3 seconds / 0.1 interval = 30 steps
if pendingDelete.progress >= 1.0 {
timer.invalidate()
}
}
}
pendingDeletes[bookmarkId]?.timer = timer
}
private func executeDelete(bookmark: Bookmark) async {
do {
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
} catch {
errorMessage = "Error deleting bookmark"
await loadBookmarks(state: currentState)
// If delete fails, restore the bookmark
await MainActor.run {
errorMessage = "Error deleting bookmark"
if var currentBookmarks = bookmarks?.bookmarks {
currentBookmarks.insert(bookmark, at: 0)
bookmarks?.bookmarks = currentBookmarks
}
}
}
}
@MainActor
private func loadCardLayout() async {
cardLayoutStyle = await loadCardLayoutUseCase.execute()
}
}
class PendingDelete: Identifiable, ObservableObject {
let id = UUID()
let bookmark: Bookmark
@Published var progress: Double = 0.0
var timer: Timer?
var deleteTask: Task<Void, Never>?
init(bookmark: Bookmark) {
self.bookmark = bookmark
}
}

View File

@ -0,0 +1,26 @@
import SwiftUI
import Kingfisher
struct CachedAsyncImage: View {
let url: URL?
init(url: URL?) {
self.url = url
}
var body: some View {
if let url {
KFImage(url)
.placeholder {
Color.gray.opacity(0.3)
}
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
} else {
Image("placeholder")
.resizable()
.scaledToFill()
}
}
}

View File

@ -12,7 +12,5 @@
import Foundation
struct Constants {
struct Labels {
static let pageSize = 12
}
// Empty for now - can be used for other constants in the future
}

View File

@ -0,0 +1,176 @@
import SwiftUI
struct SkeletonLoadingView: View {
let layout: CardLayoutStyle
@State private var animateGradient = false
var body: some View {
LazyVStack(spacing: layout == .compact ? 8 : 12) {
ForEach(0..<6, id: \.self) { _ in
skeletonCard
}
}
.onAppear {
withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
animateGradient = true
}
}
}
@ViewBuilder
private var skeletonCard: some View {
switch layout {
case .compact:
compactSkeletonCard
case .magazine:
magazineSkeletonCard
case .natural:
naturalSkeletonCard
}
}
private var compactSkeletonCard: some View {
HStack(alignment: .top, spacing: 12) {
// Image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(shimmerGradient)
.frame(width: 80, height: 80)
VStack(alignment: .leading, spacing: 4) {
// Title placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 180, height: 16)
// Description placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 120, height: 14)
Spacer()
// Bottom info placeholder
HStack {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 80, height: 12)
Spacer()
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 50, height: 12)
}
}
}
.padding(12)
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var magazineSkeletonCard: some View {
VStack(alignment: .leading, spacing: 8) {
// Image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(shimmerGradient)
.frame(height: 140)
VStack(alignment: .leading, spacing: 4) {
// Title placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 200, height: 16)
// Info placeholders
HStack {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 80, height: 12)
Spacer()
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 60, height: 12)
}
.padding(.top, 4)
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
}
private var naturalSkeletonCard: some View {
VStack(alignment: .leading, spacing: 8) {
// Image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(shimmerGradient)
.frame(minHeight: 180)
VStack(alignment: .leading, spacing: 4) {
// Title placeholder
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 220, height: 16)
// Info placeholders
HStack {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 90, height: 12)
Spacer()
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 70, height: 12)
}
.padding(.top, 4)
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(R.color.bookmark_list_bg))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
}
private var shimmerGradient: LinearGradient {
LinearGradient(
colors: [
Color.gray.opacity(0.3),
Color.gray.opacity(0.1),
Color.gray.opacity(0.3)
],
startPoint: animateGradient ? .topLeading : .topTrailing,
endPoint: animateGradient ? .bottomTrailing : .bottomLeading
)
}
}
#Preview {
ScrollView {
SkeletonLoadingView(layout: .magazine)
.padding()
}
}

View File

@ -1,5 +1,61 @@
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 {
case url
case labels
@ -27,7 +83,6 @@ struct TagManagementView: View {
let selectedLabelsSet: Set<String>
let searchText: Binding<String>
let isLabelsLoading: Bool
let availableLabelPages: [[BookmarkLabel]]
let filteredLabels: [BookmarkLabel]
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
@ -44,7 +99,6 @@ struct TagManagementView: View {
selectedLabels: Set<String>,
searchText: Binding<String>,
isLabelsLoading: Bool,
availableLabelPages: [[BookmarkLabel]],
filteredLabels: [BookmarkLabel],
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
onAddCustomTag: @escaping () -> Void,
@ -55,7 +109,6 @@ struct TagManagementView: View {
self.selectedLabelsSet = selectedLabels
self.searchText = searchText
self.isLabelsLoading = isLabelsLoading
self.availableLabelPages = availableLabelPages
self.filteredLabels = filteredLabels
self.searchFieldFocus = searchFieldFocus
self.onAddCustomTag = onAddCustomTag
@ -138,7 +191,7 @@ struct TagManagementView: View {
.scaleEffect(0.8)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 20)
} else if availableLabelPages.isEmpty {
} else if allLabels.isEmpty {
VStack {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
@ -150,7 +203,7 @@ struct TagManagementView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
labelsTabView
labelsScrollView
}
}
.padding(.top, 8)
@ -158,28 +211,47 @@ struct TagManagementView: View {
}
@ViewBuilder
private var labelsTabView: some View {
TabView {
ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
ForEach(labelsPage, id: \.id) { label in
UnifiedLabelChip(
label: label.name,
isSelected: selectedLabelsSet.contains(label.name),
isRemovable: false,
onTap: {
onToggleLabel(label.name)
}
)
private var labelsScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 8) {
ForEach(chunkedLabels, id: \.self) { rowLabels in
HStack(alignment: .top, spacing: 8) {
ForEach(rowLabels, id: \.id) { label in
UnifiedLabelChip(
label: label.name,
isSelected: false,
isRemovable: false,
onTap: {
onToggleLabel(label.name)
}
)
}
Spacer()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal)
}
.padding(.horizontal)
}
.tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never))
.frame(height: 180)
.padding(.top, 10)
.frame(height: calculateMaxHeight())
}
private var chunkedLabels: [[BookmarkLabel]] {
let maxRows = 3
let labelsPerRow = max(1, availableUnselectedLabels.count / maxRows + (availableUnselectedLabels.count % maxRows > 0 ? 1 : 0))
return availableUnselectedLabels.chunked(into: labelsPerRow)
}
private var availableUnselectedLabels: [BookmarkLabel] {
let labelsToShow = searchText.wrappedValue.isEmpty ? allLabels : filteredLabels
return labelsToShow.filter { !selectedLabelsSet.contains($0.name) }
}
private func calculateMaxHeight() -> CGFloat {
// Berechne Höhe für maximal 3 Reihen
let rowHeight: CGFloat = 32 // Höhe eines Labels
let spacing: CGFloat = 8
let maxRows: CGFloat = 3
return (rowHeight * maxRows) + (spacing * (maxRows - 1))
}
@ViewBuilder
@ -190,11 +262,11 @@ struct TagManagementView: View {
.font(.subheadline)
.fontWeight(.medium)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
FlowLayout(spacing: 8) {
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
UnifiedLabelChip(
label: label,
isSelected: false,
isSelected: true,
isRemovable: true,
onTap: {
// No action for selected labels
@ -210,3 +282,11 @@ struct TagManagementView: View {
}
}
}
extension Array {
func chunked(into size: Int) -> [[Element]] {
return stride(from: 0, to: count, by: size).map {
Array(self[$0..<Swift.min($0 + size, count)])
}
}
}

View File

@ -0,0 +1,68 @@
import SwiftUI
struct UndoToastView: View {
let bookmarkTitle: String
let progress: Double
let onUndo: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: "trash")
.foregroundColor(.white)
.font(.system(size: 16, weight: .medium))
VStack(alignment: .leading, spacing: 4) {
Text("Bookmark deleted")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.white)
Text(bookmarkTitle)
.font(.caption)
.foregroundColor(.white.opacity(0.8))
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
Button("Undo") {
onUndo()
}
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.white.opacity(0.2))
.clipShape(Capsule())
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.black.opacity(0.85))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
// Progress bar at bottom
VStack {
Spacer()
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle(tint: .white.opacity(0.8)))
.scaleEffect(y: 0.5)
}
)
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
}
}
#Preview {
VStack {
Spacer()
UndoToastView(
bookmarkTitle: "How to Build Great Products",
progress: 0.6,
onUndo: {}
)
.padding()
}
.background(Color.gray.opacity(0.3))
}

View File

@ -18,6 +18,8 @@ protocol UseCaseFactory {
func makeGetLabelsUseCase() -> PGetLabelsUseCase
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
}
@ -102,4 +104,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
return OfflineBookmarkSyncUseCase()
}
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
return LoadCardLayoutUseCase(settingsRepository: settingsRepository)
}
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
}
}

View File

@ -77,6 +77,13 @@ class MockUseCaseFactory: UseCaseFactory {
MockAddTextToSpeechQueueUseCase()
}
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase {
MockLoadCardLayoutUseCase()
}
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
MockSaveCardLayoutUseCase()
}
}
@ -204,6 +211,18 @@ class MockOfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase {
}
}
class MockLoadCardLayoutUseCase: PLoadCardLayoutUseCase {
func execute() async -> CardLayoutStyle {
return .magazine
}
}
class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase {
func execute(layout: CardLayoutStyle) async {
// Mock implementation - do nothing
}
}
extension Bookmark {
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)

View File

@ -61,7 +61,7 @@ struct SearchBookmarksView: View {
}
}
}) {
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in }, namespace: namespace)
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in })
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
@ -91,8 +91,7 @@ struct SearchBookmarksView: View {
set: { selectedBookmarkId = $0 }
)
) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace)
.navigationTransition(.zoom(sourceID: bookmarkId, in: namespace))
BookmarkDetailView(bookmarkId: bookmarkId)
}
.onAppear {
if isFirstAppearance {

View File

@ -0,0 +1,221 @@
import SwiftUI
struct AppearanceSettingsView: View {
@State private var selectedCardLayout: CardLayoutStyle = .magazine
@State private var selectedTheme: Theme = .system
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
}
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Appearance", icon: "paintbrush")
.padding(.bottom, 4)
// Theme Section
VStack(alignment: .leading, spacing: 12) {
Text("Theme")
.font(.headline)
Picker("Theme", selection: $selectedTheme) {
ForEach(Theme.allCases, id: \.self) { theme in
Text(theme.displayName).tag(theme)
}
}
.pickerStyle(.segmented)
.onChange(of: selectedTheme) {
saveThemeSettings()
}
}
Divider()
// Card Layout Section
VStack(alignment: .leading, spacing: 12) {
Text("Card Layout")
.font(.headline)
VStack(spacing: 16) {
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
CardLayoutPreview(
layout: layout,
isSelected: selectedCardLayout == layout
) {
selectedCardLayout = layout
saveCardLayoutSettings()
}
}
}
}
}
.onAppear {
loadSettings()
}
}
private func loadSettings() {
// Load theme setting
let themeString = UserDefaults.standard.string(forKey: "selectedTheme") ?? "system"
selectedTheme = Theme(rawValue: themeString) ?? .system
// Load card layout setting
Task {
selectedCardLayout = await loadCardLayoutUseCase.execute()
}
}
private func saveThemeSettings() {
UserDefaults.standard.set(selectedTheme.rawValue, forKey: "selectedTheme")
}
private func saveCardLayoutSettings() {
Task {
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
// Notify other parts of the app about the change
await MainActor.run {
NotificationCenter.default.post(name: .cardLayoutChanged, object: selectedCardLayout)
}
}
}
}
struct CardLayoutPreview: View {
let layout: CardLayoutStyle
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 12) {
// Visual Preview
switch layout {
case .compact:
// Compact: Small image on left, content on right
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue.opacity(0.6))
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 6)
.frame(maxWidth: .infinity)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 60)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.4))
.frame(height: 4)
.frame(maxWidth: 40)
}
}
.padding(8)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 50)
case .magazine:
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 24)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 40)
Text("Fixed 140px")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 65)
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
case .natural:
VStack(spacing: 3) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.blue.opacity(0.6))
.frame(height: 38)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.8))
.frame(height: 5)
RoundedRectangle(cornerRadius: 2)
.fill(Color.primary.opacity(0.6))
.frame(height: 4)
.frame(maxWidth: 35)
Text("Original ratio")
.font(.system(size: 7))
.foregroundColor(.secondary)
.padding(.top, 1)
}
.padding(.horizontal, 4)
}
.padding(6)
.background(Color.gray.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 80, height: 75) // Höher als Magazine
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
}
VStack(alignment: .leading, spacing: 4) {
Text(layout.displayName)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
Text(layout.description)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
.font(.title2)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}
#Preview {
AppearanceSettingsView()
.cardStyle()
.padding()
}

View File

@ -0,0 +1,148 @@
import SwiftUI
import Kingfisher
struct CacheSettingsView: View {
@State private var cacheSize: String = "0 MB"
@State private var maxCacheSize: Double = 200
@State private var isClearing: Bool = false
@State private var showClearAlert: Bool = false
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Cache Settings", icon: "internaldrive")
.padding(.bottom, 4)
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Current Cache Size")
.foregroundColor(.primary)
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Refresh") {
updateCacheSize()
}
.font(.caption)
.foregroundColor(.blue)
}
Divider()
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Max Cache Size")
.foregroundColor(.primary)
Spacer()
Text("\(Int(maxCacheSize)) MB")
.font(.caption)
.foregroundColor(.secondary)
}
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
Text("Max Cache Size")
}
.onChange(of: maxCacheSize) { _, newValue in
updateMaxCacheSize(newValue)
}
.accentColor(.blue)
}
Divider()
Button(action: {
showClearAlert = true
}) {
HStack {
if isClearing {
ProgressView()
.scaleEffect(0.8)
.frame(width: 24)
} else {
Image(systemName: "trash")
.foregroundColor(.red)
.frame(width: 24)
}
VStack(alignment: .leading, spacing: 2) {
Text("Clear Cache")
.foregroundColor(isClearing ? .secondary : .red)
Text("Remove all cached images")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
.disabled(isClearing)
}
}
.onAppear {
updateCacheSize()
loadMaxCacheSize()
}
.alert("Clear Cache", isPresented: $showClearAlert) {
Button("Cancel", role: .cancel) { }
Button("Clear", role: .destructive) {
clearCache()
}
} message: {
Text("This will remove all cached images. They will be downloaded again when needed.")
}
}
private func updateCacheSize() {
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
DispatchQueue.main.async {
switch result {
case .success(let size):
let mbSize = Double(size) / (1024 * 1024)
self.cacheSize = String(format: "%.1f MB", mbSize)
case .failure:
self.cacheSize = "Unknown"
}
}
}
}
private func loadMaxCacheSize() {
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
if let savedSize = savedSize {
maxCacheSize = Double(savedSize) / (1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = savedSize
} else {
maxCacheSize = 200
let defaultBytes = UInt(200 * 1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = defaultBytes
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
}
}
private func updateMaxCacheSize(_ newSize: Double) {
let bytes = UInt(newSize * 1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
}
private func clearCache() {
isClearing = true
KingfisherManager.shared.cache.clearDiskCache {
DispatchQueue.main.async {
self.isClearing = false
self.updateCacheSize()
}
}
KingfisherManager.shared.cache.clearMemoryCache()
}
}
#Preview {
CacheSettingsView()
.cardStyle()
.padding()
}

View File

@ -15,17 +15,9 @@ struct FontSettingsView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 24) {
// Header
HStack(spacing: 8) {
Image(systemName: "textformat")
.font(.title2)
.foregroundColor(.accentColor)
Text("Font")
.font(.title2)
.fontWeight(.bold)
}
VStack(spacing: 20) {
SectionHeader(title: "Font Settings", icon: "textformat")
.padding(.bottom, 4)
// Font Family Picker
HStack(alignment: .firstTextBaseline, spacing: 16) {

View File

@ -21,6 +21,12 @@ struct SettingsContainerView: View {
FontSettingsView()
.cardStyle()
AppearanceSettingsView()
.cardStyle()
CacheSettingsView()
.cardStyle()
SettingsGeneralView()
.cardStyle()
@ -103,9 +109,19 @@ struct SettingsContainerView: View {
HStack(spacing: 8) {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
Text("Developer: Ilyas Hallak")
HStack(spacing: 4) {
Text("Developer:")
.font(.footnote)
.foregroundColor(.secondary)
Button("Ilyas Hallak") {
if let url = URL(string: "https://ilyashallak.de") {
UIApplication.shared.open(url)
}
}
.font(.footnote)
.foregroundColor(.secondary)
.foregroundColor(.blue)
.underline()
}
}
HStack(spacing: 8) {
Image(systemName: "globe")

View File

@ -19,23 +19,6 @@ struct SettingsGeneralView: View {
SectionHeader(title: "General Settings", icon: "gear")
.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) {
Text("General")
.font(.headline)
@ -78,38 +61,6 @@ struct SettingsGeneralView: View {
.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
if let successMessage = viewModel.successMessage {
HStack {

View File

@ -52,7 +52,7 @@ class SettingsGeneralViewModel {
successMessage = "Settings saved"
// send notification to apply settings to the app
NotificationCenter.default.post(name: NSNotification.Name("SettingsChanged"), object: nil)
NotificationCenter.default.post(name: .settingsChanged, object: nil)
} catch {
errorMessage = "Error saving settings"
}

View File

@ -141,16 +141,18 @@ struct SettingsServerView: View {
Button(action: {
showingLogoutAlert = true
}) {
HStack {
HStack(spacing: 6) {
Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.caption)
Text("Logout")
.fontWeight(.semibold)
.font(.caption)
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.cornerRadius(8)
}
}
}

View File

@ -67,7 +67,7 @@ class SettingsServerViewModel {
isLoggedIn = true
successMessage = "Server settings saved and successfully logged in."
try await SettingsRepository().saveHasFinishedSetup(true)
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
} catch {
errorMessage = "Connection or login failed: \(error.localizedDescription)"
isLoggedIn = false
@ -80,7 +80,7 @@ class SettingsServerViewModel {
try await logoutUseCase.execute()
isLoggedIn = false
successMessage = "Logged out"
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
} catch {
errorMessage = "Error logging out"
}

View File

@ -0,0 +1,20 @@
import Foundation
extension Notification.Name {
// MARK: - App Lifecycle
static let settingsChanged = Notification.Name("SettingsChanged")
static let setupStatusChanged = Notification.Name("SetupStatusChanged")
// MARK: - Authentication
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
// MARK: - Network
static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable")
// MARK: - UI Interactions
static let dismissKeyboard = Notification.Name("DismissKeyboard")
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")
// MARK: - User Preferences
static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
}

View File

@ -35,7 +35,7 @@ struct readeckApp: App {
await loadAppSettings()
}
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SettingsChanged"))) { _ in
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
Task {
await loadAppSettings()
}

View File

@ -51,6 +51,7 @@
<attribute name="src" optional="YES" attributeType="String"/>
</entity>
<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="fontFamily" optional="YES" attributeType="String"/>
<attribute name="fontSize" optional="YES" attributeType="String"/>