diff --git a/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/FeedDB.swift b/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/FeedDB.swift index 2790c45b..e68554fd 100644 --- a/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/FeedDB.swift +++ b/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Database/FeedDB.swift @@ -77,6 +77,19 @@ extension FeedDB: ModelFavoritable { } } +extension FeedDB: ModelReadable { + public static func markAllAsRead(using context: ModelContext?) { + let descriptor = FetchDescriptor(predicate: #Predicate { !$0.read }) + guard let context, + let data = try? context.fetch(descriptor) else { return } + for post in data { + post.read = true + post.modifiedAt = Date() + } + try? context.save() + } +} + extension FeedDB: ModelDuplicable { public static func deduplicate(using context: ModelContext?) { let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\FeedDB.pubDate, order: .reverse)]) diff --git a/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/FeedDBTests.swift b/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/FeedDBTests.swift index d7faeb9b..e2c084b3 100644 --- a/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/FeedDBTests.swift +++ b/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/FeedDBTests.swift @@ -321,7 +321,67 @@ struct FeedDBTests { #expect(remaining.isEmpty) } - // MARK: - Integration Tests + // MARK: - ModelReadable Tests + + @Test("markAllAsRead should mark all unread posts as read") + func markAllAsReadMarksUnreadPosts() { + let storage = Database(models: [FeedDB.self], inMemory: true) + storage.context.insert(FeedDB(postId: "1", title: "Unread 1")) + storage.context.insert(FeedDB(postId: "2", title: "Unread 2")) + storage.context.insert(FeedDB(postId: "3", title: "Unread 3")) + try? storage.context.save() + + FeedDB.markAllAsRead(using: storage.context) + + let posts = storage.fetch(FeedDB.self) + #expect(posts.count == 3) + #expect(posts.allSatisfy { $0.read == true }) + } + + @Test("markAllAsRead should not affect already-read posts") + func markAllAsReadPreservesAlreadyRead() { + let storage = Database(models: [FeedDB.self], inMemory: true) + let originalDate = Date(timeIntervalSince1970: 1000) + let alreadyRead = FeedDB(postId: "1", title: "Already Read", modifiedAt: originalDate) + alreadyRead.read = true + storage.context.insert(alreadyRead) + storage.context.insert(FeedDB(postId: "2", title: "Unread")) + try? storage.context.save() + + FeedDB.markAllAsRead(using: storage.context) + + let posts = storage.fetch(FeedDB.self) + #expect(posts.allSatisfy { $0.read == true }) + let readPost = posts.first { $0.postId == "1" } + #expect(readPost?.modifiedAt == originalDate) + } + + @Test("markAllAsRead should handle empty database") + func markAllAsReadHandlesEmptyDatabase() { + let storage = Database(models: [FeedDB.self], inMemory: true) + FeedDB.markAllAsRead(using: storage.context) + #expect(storage.fetch(FeedDB.self).isEmpty) + } + + @Test("markAllAsRead with nil context does not crash") + func markAllAsReadNilContext() { + FeedDB.markAllAsRead(using: nil) + } + + @Test("markAllAsRead should update modifiedAt on newly-read posts") + func markAllAsReadUpdatesModifiedAt() { + let storage = Database(models: [FeedDB.self], inMemory: true) + let oldDate = Date(timeIntervalSince1970: 1000) + let post = FeedDB(postId: "1", title: "Old Post", modifiedAt: oldDate) + storage.context.insert(post) + try? storage.context.save() + + FeedDB.markAllAsRead(using: storage.context) + + let fetched = storage.fetch(FeedDB.self).first + #expect(fetched?.read == true) + #expect(fetched?.modifiedAt != oldDate) + } // MARK: - ModelDuplicable Tests diff --git a/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/EnvironmentValuesExtensions.swift b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/EnvironmentValuesExtensions.swift index 7b3825c7..eec0b417 100644 --- a/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/EnvironmentValuesExtensions.swift +++ b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/EnvironmentValuesExtensions.swift @@ -24,4 +24,7 @@ public extension EnvironmentValues { @MainActor @Entry var removeAds: Bool = false + + @MainActor @Entry + var highlightPostRead: Bool = true } diff --git a/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/ModelProtocols.swift b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/ModelProtocols.swift index 388ce400..22e3ce4a 100644 --- a/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/ModelProtocols.swift +++ b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/ModelProtocols.swift @@ -5,6 +5,10 @@ public protocol ModelFavoritable: AnyObject, PersistentModel { static func deleteNonFavorites(using context: ModelContext?) } +public protocol ModelReadable: AnyObject, PersistentModel { + static func markAllAsRead(using context: ModelContext?) +} + public protocol ModelDuplicable: AnyObject, PersistentModel { static func deduplicate(using context: ModelContext?) } diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Model/CardContent.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Model/CardContent.swift index 90c44941..576bc761 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Model/CardContent.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Model/CardContent.swift @@ -71,7 +71,9 @@ public struct CardContent { public let artworkUrl: String public let urlToShare: String public let favorite: Bool + public let read: Bool public let favoriteAction: () -> Void + public let readAction: (() -> Void)? /// The aspect ratio for the card thumbnail. /// When `nil`, the thumbnail fills its parent frame (useful for externally-sized cards like carousels). @@ -86,8 +88,10 @@ public struct CardContent { artworkUrl: String, urlToShare: String, favorite: Bool, + read: Bool = false, aspectRatio: CGFloat? = 16 / 9, - favoriteAction: @escaping () -> Void + favoriteAction: @escaping () -> Void, + readAction: (() -> Void)? = nil ) { self.type = type self.title = title @@ -97,7 +101,9 @@ public struct CardContent { self.urlToShare = urlToShare self.artworkUrl = artworkUrl self.favorite = favorite + self.read = read self.aspectRatio = aspectRatio self.favoriteAction = favoriteAction + self.readAction = readAction } } diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Views/Components/Menu/MenuButton.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Views/Components/Menu/MenuButton.swift index 75d7db89..01b06a57 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Views/Components/Menu/MenuButton.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Views/Components/Menu/MenuButton.swift @@ -25,6 +25,12 @@ public struct MenuContent: View { } public var body: some View { + if let readAction = data.readAction { + Button(readText, systemImage: readImage) { + readAction() + } + } + Button("Favorito", systemImage: favoriteImage) { data.favoriteAction() } @@ -41,4 +47,12 @@ private extension MenuContent { var favoriteImage: String { data.favorite ? "star.fill" : "star" } + + var readText: String { + data.read ? "Marcar como não lido" : "Marcar como lido" + } + + var readImage: String { + data.read ? "circle" : "circle.fill" + } } diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Views/NewsCard.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Views/NewsCard.swift index 29a7a343..6c71db7f 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Views/NewsCard.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Cards/Views/NewsCard.swift @@ -3,6 +3,7 @@ import SwiftUI public struct NewsCard: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.highlightPostRead) private var highlightPostRead let data: CardContent let onSelect: () -> Void @@ -18,6 +19,7 @@ public struct NewsCard: View { public var body: some View { Button(action: { onSelect() }, label: { content }) + .opacity(data.read && highlightPostRead ? 0.6 : 1) } } diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/FeedDBExtensions.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/FeedDBExtensions.swift index 7631b805..f3776edd 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/FeedDBExtensions.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Extensions/FeedDBExtensions.swift @@ -25,6 +25,7 @@ public extension FeedDB { artworkUrl: self.artworkURL, urlToShare: self.link, favorite: self.favorite, + read: self.read, aspectRatio: aspectRatio, favoriteAction: { [weak self] in guard let self, let context else { return } @@ -35,6 +36,12 @@ public extension FeedDB { buttonId: AnalyticsConstants.ButtonID.newsFavorite.id, screen: screen ?? type.screenName )) + }, + readAction: { [weak self] in + guard let self, let context else { return } + self.read.toggle() + self.modifiedAt = Date() + try? context.save() } ) } diff --git a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift index ed00f6b1..b807cd2d 100644 --- a/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift +++ b/MacMagazine/Features/NewsLibrary/Sources/NewsLibrary/Views/Components/FeedHighlightCardView.swift @@ -16,6 +16,7 @@ public struct FeedHighlightCardView: View { let post: FeedDB @Environment(\.modelContext) private var modelContext + @Environment(\.highlightPostRead) private var highlightPostRead @EnvironmentObject private var analytics: AnalyticsManager // MARK: - Body @@ -29,7 +30,8 @@ public struct FeedHighlightCardView: View { aspectRatio: nil ) GlassCardView(data: data) - .compositingGroup() + .opacity(post.read && highlightPostRead ? 0.6 : 1) + .compositingGroup() } // MARK: - Init diff --git a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/Databases/SettingsDB.swift b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/Databases/SettingsDB.swift index 5139cd2e..5e54f0fe 100644 --- a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/Databases/SettingsDB.swift +++ b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/Databases/SettingsDB.swift @@ -8,6 +8,7 @@ public final class SettingsDB: Equatable { var mode = ColorScheme.system var icon = IconType.normal var notification: String = PushPreferences.all.rawValue + var postRead: Bool = true var subscription: Subscription = Subscription(isPatrao: false, expirationDate: Date()) var modifiedAt: Date = Date() @@ -16,6 +17,7 @@ public final class SettingsDB: Equatable { mode: ColorScheme = .system, icon: IconType = .normal, notification: String = PushPreferences.all.rawValue, + postRead: Bool = true, subscription: Subscription? = nil, modifiedAt: Date = Date() ) { @@ -23,6 +25,7 @@ public final class SettingsDB: Equatable { self.mode = mode self.icon = icon self.notification = notification + self.postRead = postRead self.modifiedAt = modifiedAt let date = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() diff --git a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/Extensions/Database.swift b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/Extensions/Database.swift index 65f61655..cb480d21 100644 --- a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/Extensions/Database.swift +++ b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/Extensions/Database.swift @@ -50,6 +50,17 @@ extension Database { try? context.save() } + @MainActor + func update(postRead: Bool) { + if let item = settings { + item.postRead = postRead + item.modifiedAt = Date() + } else { + context.insert(SettingsDB(postRead: postRead)) + } + try? context.save() + } + @MainActor func update(isPatrao: Bool) { let date = Calendar.current.date(byAdding: .day, value: isPatrao ? +30 : -1, to: Date()) ?? Date() diff --git a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/ReadingPreferencesViewModel.swift b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/ReadingPreferencesViewModel.swift new file mode 100644 index 00000000..7d6f09a8 --- /dev/null +++ b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/ReadingPreferencesViewModel.swift @@ -0,0 +1,38 @@ +import Foundation +import MacMagazineLibrary +import StorageLibrary +import SwiftData + +@Observable +final class ReadingPreferencesViewModel { + var postRead: Bool = true + var storage: Database? + var models: [any PersistentModel.Type] = [] +} + +extension ReadingPreferencesViewModel { + @MainActor + func set( + storage: Database?, + models: [any PersistentModel.Type] + ) { + self.storage = storage + self.models = models + self.postRead = storage?.settings?.postRead ?? true + } +} + +extension ReadingPreferencesViewModel { + @MainActor + func change(postRead: Bool) async { + storage?.update(postRead: postRead) + } + + @MainActor + func markAllAsRead() { + let context = storage?.sharedModelContainer.mainContext + models.forEach { + ($0 as? any ModelReadable.Type)?.markAllAsRead(using: context) + } + } +} diff --git a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/SettingsViewModel.swift b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/SettingsViewModel.swift index 6781d36b..c70037a2 100644 --- a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/SettingsViewModel.swift +++ b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/ViewModels/SettingsViewModel.swift @@ -12,6 +12,7 @@ final public class SettingsViewModel { public var news: [News] = News.allCases public var isLive = false public var removeAds = false + public var highlightPostRead = false private var storedTabs: [AppTabs] = AppTabs.allCases @@ -39,6 +40,7 @@ final public class SettingsViewModel { self.social = self.storage.customization?.social ?? Social.allCases self.news = self.storage.customization?.news ?? News.allCases self.removeAds = self.storage.settings?.subscription.removeAds ?? false + self.highlightPostRead = self.storage.settings?.postRead ?? true updateSchema() @@ -53,6 +55,7 @@ final public class SettingsViewModel { self?.social = self?.storage.customization?.social ?? Social.allCases self?.news = self?.storage.customization?.news ?? News.allCases self?.removeAds = self?.storage.settings?.subscription.removeAds ?? false + self?.highlightPostRead = self?.storage.settings?.postRead ?? true } } } diff --git a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Views/SettingsView.swift b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Views/SettingsView.swift index dce3e3d5..ead56a30 100644 --- a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Views/SettingsView.swift +++ b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Views/SettingsView.swift @@ -69,6 +69,7 @@ private extension SettingsView { NavigationLink { List { AppearanceView() + ReadingPreferencesView() IconsView() CustomTabView() CustomSocialView() diff --git a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Views/Supplementals/ReadingPreferencesView.swift b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Views/Supplementals/ReadingPreferencesView.swift new file mode 100644 index 00000000..ca975981 --- /dev/null +++ b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Views/Supplementals/ReadingPreferencesView.swift @@ -0,0 +1,70 @@ +import AnalyticsLibrary +import MacMagazineLibrary +import SwiftUI +import UIComponentsLibrary + +struct ReadingPreferencesView: View { + @EnvironmentObject private var analytics: AnalyticsManager + @Environment(\.theme) private var theme: ThemeColor + @Environment(SettingsViewModel.self) private var settingsViewModel + @State private var viewModel = ReadingPreferencesViewModel() + @State private var showReadConfirmation = false + + var body: some View { + Section { + Toggle("Identificar posts já lidos", isOn: $viewModel.postRead) + .tint(theme.button.primary.color) + + Button(action: { + viewModel.markAllAsRead() + showReadConfirmation = true + analytics.track(.buttonTap( + buttonId: AnalyticsConstants.ButtonID.cleanPostsOptions.id, + screen: AnalyticsConstants.Screen.settingsAppearance.name + )) + }, + label: { + Text("Marcar todos como lidos") + .foregroundStyle(theme.main.tint.color ?? .blue) + }) + } header: { + Text("Leitura") + .font(.headline) + .foregroundColor(theme.text.terciary.color) + } footer: { + Text("Marca visualmente os posts que você já leu") + .accessibilityHidden(true) + } + .alert("Todos os posts marcados como lido", + isPresented: $showReadConfirmation) { + Button("OK", role: .cancel) {} + } + .task { + viewModel.set( + storage: settingsViewModel.storage, + models: settingsViewModel.models + ) + } + .onChange(of: viewModel.postRead) { _, value in + analytics.track(.buttonTap( + buttonId: AnalyticsConstants.ButtonID.theme("\(value)").id, + screen: AnalyticsConstants.Screen.settingsAppearance.name + )) + Task { await viewModel.change(postRead: value) } + } + } +} + +#if DEBUG +import StorageLibrary + +#Preview { + let storage = Database(models: [SettingsDB.self], inMemory: true) + + List { + ReadingPreferencesView() + } + .environment(\.theme, ThemeColor()) + .environment(SettingsViewModel(storage: storage, models: [])) +} +#endif diff --git a/MacMagazine/MacMagazine/MainApp/MacMagazineApp.swift b/MacMagazine/MacMagazine/MainApp/MacMagazineApp.swift index 47903f5d..fac67608 100644 --- a/MacMagazine/MacMagazine/MainApp/MacMagazineApp.swift +++ b/MacMagazine/MacMagazine/MainApp/MacMagazineApp.swift @@ -132,6 +132,7 @@ private extension SceneView { .environment(viewModel.searchViewModel) .environment(podcastPlayerManager) .environment(\.removeAds, viewModel.settingsViewModel.removeAds) + .environment(\.highlightPostRead, viewModel.settingsViewModel.highlightPostRead) .environment(viewModel.sessionState) .environmentObject(viewModel.analytics) .preferredColorScheme(viewModel.settingsViewModel.colorSchema)