diff --git a/MacMagazine/MacMagazine.xcodeproj/project.pbxproj b/MacMagazine/MacMagazine.xcodeproj/project.pbxproj index d340d158..e6b0ab71 100644 --- a/MacMagazine/MacMagazine.xcodeproj/project.pbxproj +++ b/MacMagazine/MacMagazine.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 88C8B2E72EF476E100044DE9 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9BDD424E2EE9E82F00A9BD85 /* WidgetKit.framework */; }; + 88C8B2E82EF476E100044DE9 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9BDD42502EE9E82F00A9BD85 /* SwiftUI.framework */; }; + 88C8B2F72EF476E200044DE9 /* WidgetWatchExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 88C8B2E62EF476E100044DE9 /* WidgetWatchExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 88C8B2FE2EF48DC500044DE9 /* FeedLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 88C8B2FD2EF48DC500044DE9 /* FeedLibrary */; }; 9B024FDC2EC2719700D66056 /* SettingsLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 9B024FDB2EC2719700D66056 /* SettingsLibrary */; }; 9B1A43032EE0C14E00E56784 /* PodcastLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 9B1A43022EE0C14E00E56784 /* PodcastLibrary */; }; 9B4F666F2EE9F223008ACACB /* FeedLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 9B4F666E2EE9F223008ACACB /* FeedLibrary */; }; @@ -23,6 +27,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 88C8B2F52EF476E200044DE9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9B9E9DBC2E986F8400255820 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 88C8B2E52EF476E100044DE9; + remoteInfo = WidgetWatchExtension; + }; 9BA828AA2EEC7AAB00F26FCB /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 9B9E9DBC2E986F8400255820 /* Project object */; @@ -40,6 +51,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 88C8B2FC2EF476E200044DE9 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 88C8B2F72EF476E200044DE9 /* WidgetWatchExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9BA828AD2EEC7AAB00F26FCB /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -65,6 +87,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 88C8B2E62EF476E100044DE9 /* WidgetWatchExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetWatchExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 88C8B3012EF4AE7400044DE9 /* WidgetWatchExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetWatchExtension.entitlements; sourceTree = ""; }; 9B024FDA2EC2717200D66056 /* SettingsLibrary */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SettingsLibrary; sourceTree = ""; }; 9B1A43012EE0C04D00E56784 /* PodcastLibrary */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = PodcastLibrary; sourceTree = ""; }; 9B334E912EE43FE4009F4947 /* MMLiveLibrary */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MMLiveLibrary; sourceTree = ""; }; @@ -81,6 +105,20 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 88C8B2FB2EF476E200044DE9 /* Exceptions for "WidgetWatch" folder in "WidgetWatchExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 88C8B2E52EF476E100044DE9 /* WidgetWatchExtension */; + }; + 88C8B3052EF577EE00044DE9 /* Exceptions for "WatchApp" folder in "WidgetWatchExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + WatchWidgetShared.swift, + ); + target = 88C8B2E52EF476E100044DE9 /* WidgetWatchExtension */; + }; 9B9E9DD12E986F8500255820 /* Exceptions for "MacMagazine" folder in "MacMagazine" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -105,6 +143,14 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 88C8B2E92EF476E100044DE9 /* WidgetWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 88C8B2FB2EF476E200044DE9 /* Exceptions for "WidgetWatch" folder in "WidgetWatchExtension" target */, + ); + path = WidgetWatch; + sourceTree = ""; + }; 9B9E9DC62E986F8400255820 /* MacMagazine */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -116,6 +162,9 @@ }; 9BA8288D2EEC7AAA00F26FCB /* WatchApp */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 88C8B3052EF577EE00044DE9 /* Exceptions for "WatchApp" folder in "WidgetWatchExtension" target */, + ); path = WatchApp; sourceTree = ""; }; @@ -130,6 +179,16 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 88C8B2E32EF476E100044DE9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 88C8B2FE2EF48DC500044DE9 /* FeedLibrary in Frameworks */, + 88C8B2E82EF476E100044DE9 /* SwiftUI.framework in Frameworks */, + 88C8B2E72EF476E100044DE9 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9B9E9DC12E986F8400255820 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -168,11 +227,13 @@ 9B9E9DBB2E986F8400255820 = { isa = PBXGroup; children = ( + 88C8B3012EF4AE7400044DE9 /* WidgetWatchExtension.entitlements */, 9BB3888E2EE0B2E4007AC71C /* MacMagazine.xctestplan */, 9B9E9DDA2E98701C00255820 /* Features */, 9B9E9DC62E986F8400255820 /* MacMagazine */, 9BDD42522EE9E82F00A9BD85 /* Widget */, 9BA8288D2EEC7AAA00F26FCB /* WatchApp */, + 88C8B2E92EF476E100044DE9 /* WidgetWatch */, 9B9E9DDE2E98709000255820 /* Frameworks */, 9B9E9DC52E986F8400255820 /* Products */, ); @@ -184,6 +245,7 @@ 9B9E9DC42E986F8400255820 /* MacMagazine.app */, 9BDD424D2EE9E82F00A9BD85 /* WidgetExtension.appex */, 9BA8288C2EEC7AAA00F26FCB /* WatchApp.app */, + 88C8B2E62EF476E100044DE9 /* WidgetWatchExtension.appex */, ); name = Products; sourceTree = ""; @@ -214,6 +276,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 88C8B2E52EF476E100044DE9 /* WidgetWatchExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 88C8B2F82EF476E200044DE9 /* Build configuration list for PBXNativeTarget "WidgetWatchExtension" */; + buildPhases = ( + 88C8B2E22EF476E100044DE9 /* Sources */, + 88C8B2E32EF476E100044DE9 /* Frameworks */, + 88C8B2E42EF476E100044DE9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 88C8B2E92EF476E100044DE9 /* WidgetWatch */, + ); + name = WidgetWatchExtension; + packageProductDependencies = ( + 88C8B2FD2EF48DC500044DE9 /* FeedLibrary */, + ); + productName = WidgetWatchExtension; + productReference = 88C8B2E62EF476E100044DE9 /* WidgetWatchExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 9B9E9DC32E986F8400255820 /* MacMagazine */ = { isa = PBXNativeTarget; buildConfigurationList = 9B9E9DD22E986F8500255820 /* Build configuration list for PBXNativeTarget "MacMagazine" */; @@ -254,10 +339,12 @@ 9BA828882EEC7AAA00F26FCB /* Sources */, 9BA828892EEC7AAA00F26FCB /* Frameworks */, 9BA8288A2EEC7AAA00F26FCB /* Resources */, + 88C8B2FC2EF476E200044DE9 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 88C8B2F62EF476E200044DE9 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 9BA8288D2EEC7AAA00F26FCB /* WatchApp */, @@ -301,9 +388,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2610; + LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 2610; TargetAttributes = { + 88C8B2E52EF476E100044DE9 = { + CreatedOnToolsVersion = 26.2; + }; 9B9E9DC32E986F8400255820 = { CreatedOnToolsVersion = 26.0.1; }; @@ -334,11 +424,19 @@ 9B9E9DC32E986F8400255820 /* MacMagazine */, 9BDD424C2EE9E82F00A9BD85 /* WidgetExtension */, 9BA8288B2EEC7AAA00F26FCB /* WatchApp */, + 88C8B2E52EF476E100044DE9 /* WidgetWatchExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 88C8B2E42EF476E100044DE9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9B9E9DC22E986F8400255820 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -385,6 +483,13 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 88C8B2E22EF476E100044DE9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9B9E9DC02E986F8400255820 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -409,6 +514,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 88C8B2F62EF476E200044DE9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 88C8B2E52EF476E100044DE9 /* WidgetWatchExtension */; + targetProxy = 88C8B2F52EF476E200044DE9 /* PBXContainerItemProxy */; + }; 9BA828AB2EEC7AAB00F26FCB /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 9BA8288B2EEC7AAA00F26FCB /* WatchApp */; @@ -422,6 +532,74 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 88C8B2F92EF476E200044DE9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = WidgetWatchExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 5.1.0; + DEVELOPMENT_TEAM = A5VW9QUF9L; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetWatch/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MacMagazine; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 5.1; + PRODUCT_BUNDLE_IDENTIFIER = com.brit.beta.macmagazine.watchkitapp.WidgetWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + 88C8B2FA2EF476E200044DE9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = WidgetWatchExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 5.1.0; + DEVELOPMENT_TEAM = A5VW9QUF9L; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetWatch/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MacMagazine; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 5.1; + PRODUCT_BUNDLE_IDENTIFIER = com.brit.beta.macmagazine.watchkitapp.WidgetWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; 9B9E9DD32E986F8500255820 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -650,7 +828,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 26.1; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -685,7 +863,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 26.1; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; @@ -754,6 +932,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 88C8B2F82EF476E200044DE9 /* Build configuration list for PBXNativeTarget "WidgetWatchExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 88C8B2F92EF476E200044DE9 /* Debug */, + 88C8B2FA2EF476E200044DE9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 9B9E9DBF2E986F8400255820 /* Build configuration list for PBXProject "MacMagazine" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -793,6 +980,10 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + 88C8B2FD2EF48DC500044DE9 /* FeedLibrary */ = { + isa = XCSwiftPackageProductDependency; + productName = FeedLibrary; + }; 9B024FDB2EC2719700D66056 /* SettingsLibrary */ = { isa = XCSwiftPackageProductDependency; productName = SettingsLibrary; diff --git a/MacMagazine/WatchApp/MainApp/WatchApp.swift b/MacMagazine/WatchApp/MainApp/WatchApp.swift index a2e87953..b8a424b5 100644 --- a/MacMagazine/WatchApp/MainApp/WatchApp.swift +++ b/MacMagazine/WatchApp/MainApp/WatchApp.swift @@ -2,12 +2,17 @@ import FeedLibrary import StorageLibrary import SwiftData import SwiftUI +import UserNotifications +import WatchKit @main struct WatchApp: App { private let database = Database(models: [FeedDB.self], inMemory: false) + @WKApplicationDelegateAdaptor(WatchNotificationsDelegate.self) + private var notifDelegate + var body: some Scene { WindowGroup { FeedMainView( @@ -16,6 +21,23 @@ struct WatchApp: App { ) ) .modelContainer(database.sharedModelContainer) + .onOpenURL { url in + handleDeepLink(url) + } + } + } + + @MainActor + private func handleDeepLink(_ url: URL) { + guard url.scheme == "macmagazine" else { return } + + let host = url.host ?? "" + let pathComponents = url.pathComponents.filter { $0 != "/" } + + guard host == "news" else { return } + + if pathComponents.count >= 2, pathComponents[0] == "post" { + _ = pathComponents[1] } } } diff --git a/MacMagazine/WatchApp/MainApp/WatchNotificationsDelegate.swift b/MacMagazine/WatchApp/MainApp/WatchNotificationsDelegate.swift new file mode 100644 index 00000000..a1cde677 --- /dev/null +++ b/MacMagazine/WatchApp/MainApp/WatchNotificationsDelegate.swift @@ -0,0 +1,55 @@ +import Foundation +import UserNotifications +import WatchKit +import WidgetKit + +final class WatchNotificationsDelegate: NSObject, + WKApplicationDelegate, + UNUserNotificationCenterDelegate { + + func applicationDidFinishLaunching() { + UNUserNotificationCenter.current().delegate = self + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + handlePush(userInfo: response.notification.request.content.userInfo) + } + + // MARK: - Push handling + + private func handlePush(userInfo: [AnyHashable: Any]) { + guard + let postId = userInfo["postId"] as? String, + let title = userInfo["title"] as? String, + !postId.isEmpty, + !title.isEmpty + else { + return + } + + let date: Date = { + if let time = userInfo["date"] as? TimeInterval { + return Date(timeIntervalSince1970: time) + } + if let iso = userInfo["date"] as? String, + let date = ISO8601DateFormatter().date(from: iso) { + return date + } + return Date() + }() + + MacMagazineWidgetSharedStore.write( + snapshot: .init( + postId: postId, + title: title, + date: date + ) + ) + + // Atualiza as complicações + WidgetCenter.shared.reloadTimelines(ofKind: "WidgetWatch") + } +} diff --git a/MacMagazine/WatchApp/ViewModel/FeedMainViewModel.swift b/MacMagazine/WatchApp/ViewModel/FeedMainViewModel.swift index 912a4f24..57189732 100644 --- a/MacMagazine/WatchApp/ViewModel/FeedMainViewModel.swift +++ b/MacMagazine/WatchApp/ViewModel/FeedMainViewModel.swift @@ -3,6 +3,7 @@ import Foundation import StorageLibrary import SwiftData import WatchKit +import WidgetKit @MainActor @Observable @@ -20,8 +21,12 @@ final class FeedMainViewModel { // MARK: - Private private let feedViewModel: FeedViewModel + private var didLoadInitial: Bool = false + private var lastRefreshAt: Date? + private let staleInterval: TimeInterval = 30 * 60 // MARK: - Init + init(feedViewModel: FeedViewModel) { self.feedViewModel = feedViewModel status = feedViewModel.status @@ -29,16 +34,38 @@ final class FeedMainViewModel { // MARK: - Public API + func loadInitialIfNeeded(hasItems: Bool, modelContext: ModelContext) async { + guard !didLoadInitial else { return } + didLoadInitial = true + + if hasItems { + status = .done + persistLatestPostSnapshot(from: modelContext) + } + + let shouldAutoRefresh: Bool = { + if !hasItems { return true } + guard let last = lastRefreshAt else { return true } + return Date().timeIntervalSince(last) > staleInterval + }() + + if shouldAutoRefresh { + await refresh(modelContext: modelContext) + } + } + func refresh(modelContext: ModelContext) async { guard !isRefreshing else { return } isRefreshing = true - - defer { - isRefreshing = false - } + defer { isRefreshing = false } _ = try? await feedViewModel.getWatchFeed() status = feedViewModel.status + lastRefreshAt = Date() + + persistLatestPostSnapshot(from: modelContext) + + WidgetCenter.shared.reloadTimelines(ofKind: "WidgetWatch") } func toggleFavorite(post: FeedDB, modelContext: ModelContext) { @@ -47,6 +74,25 @@ final class FeedMainViewModel { showActions = true } + // MARK: - Snapshot para Widget + + private func persistLatestPostSnapshot(from modelContext: ModelContext) { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.pubDate, order: .reverse)] + ) + descriptor.fetchLimit = 1 + + guard let last = try? modelContext.fetch(descriptor).first else { return } + + MacMagazineWidgetSharedStore.write( + snapshot: .init( + postId: last.postId, + title: last.title, + date: last.pubDate + ) + ) + } + // MARK: - Index / Helpers func computeSelectedIndexByMidY(items: [FeedDB], positions: [String: CGPoint]) -> Int { @@ -74,6 +120,15 @@ final class FeedMainViewModel { guard quantity > 0 else { return 0 } return min(max(index, 0), quantity - 1) } + + func openPost(withId postId: String, modelContext: ModelContext) { + let predicate = #Predicate { $0.postId == postId } + let descriptor = FetchDescriptor(predicate: predicate) + + if let post = try? modelContext.fetch(descriptor).first { + selectedPostForDetail = post + } + } } #if DEBUG diff --git a/MacMagazine/WatchApp/Views/FeedMainView.swift b/MacMagazine/WatchApp/Views/FeedMainView.swift index 32744b7f..c525856b 100644 --- a/MacMagazine/WatchApp/Views/FeedMainView.swift +++ b/MacMagazine/WatchApp/Views/FeedMainView.swift @@ -31,8 +31,25 @@ struct FeedMainView: View { var body: some View { NavigationStack { rootContent - .navigationDestination(item: $viewModel.selectedPostForDetail) { payload in - FeedDetailView(viewModel: viewModel, post: payload) + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadInitialIfNeeded( + hasItems: !items.isEmpty, + modelContext: modelContext + ) + } + .navigationDestination(item: $viewModel.selectedPostForDetail) { post in + FeedDetailView(viewModel: viewModel, post: post) + } + .onOpenURL { url in + guard url.scheme == "macmagazine" else { return } + guard url.host == "news" else { return } + + let parts = url.pathComponents.filter { $0 != "/" } + if parts.count >= 2, parts[0] == "post" { + let postId = parts[1] + viewModel.openPost(withId: postId, modelContext: modelContext) + } } } .task { @@ -193,6 +210,7 @@ struct FeedMainView: View { ) .frame(maxHeight: .infinity, alignment: .trailing) .opacity(viewModel.isRefreshing ? 0 : 1) + .padding(.trailing, 4) } .overlay { if viewModel.isRefreshing { diff --git a/MacMagazine/WatchApp/Views/Row/FeedRowView.swift b/MacMagazine/WatchApp/Views/Row/FeedRowView.swift index e5c1f128..184c65d8 100644 --- a/MacMagazine/WatchApp/Views/Row/FeedRowView.swift +++ b/MacMagazine/WatchApp/Views/Row/FeedRowView.swift @@ -62,7 +62,7 @@ struct FeedRowView: View { .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(3) - .padding(.trailing, 12) + .padding(.trailing, 22) Text(post.dateText) .font(.caption2) diff --git a/MacMagazine/WatchApp/WatchApp.entitlements b/MacMagazine/WatchApp/WatchApp.entitlements index 2284d00f..a44bc9b3 100644 --- a/MacMagazine/WatchApp/WatchApp.entitlements +++ b/MacMagazine/WatchApp/WatchApp.entitlements @@ -12,5 +12,9 @@ CloudKit + com.apple.security.application-groups + + group.com.brit.beta.macmagazine + diff --git a/MacMagazine/WatchApp/WatchWidgetShared.swift b/MacMagazine/WatchApp/WatchWidgetShared.swift new file mode 100644 index 00000000..33ed17de --- /dev/null +++ b/MacMagazine/WatchApp/WatchWidgetShared.swift @@ -0,0 +1,42 @@ +import Foundation + +enum MacMagazineWidgetSharedStore { + static let appGroupID = "group.com.brit.beta.macmagazine" + + private enum Keys { + static let lastPostTitle = "macmagazine.widget.lastPost.title" + static let lastPostDate = "macmagazine.widget.lastPost.date" + static let lastPostId = "macmagazine.widget.lastPost.id" + } + + struct Snapshot: Equatable { + let postId: String + let title: String + let date: Date + } + + static func write(snapshot: Snapshot) { + guard let defaults = UserDefaults(suiteName: appGroupID) else { return } + + defaults.set(snapshot.title, forKey: Keys.lastPostTitle) + defaults.set(snapshot.date, forKey: Keys.lastPostDate) + defaults.set(snapshot.postId, forKey: Keys.lastPostId) + defaults.synchronize() + } + + static func readSnapshot() -> Snapshot? { + guard let defaults = UserDefaults(suiteName: appGroupID) else { return nil } + + guard + let title = defaults.string(forKey: Keys.lastPostTitle), + let date = defaults.object(forKey: Keys.lastPostDate) as? Date, + let postId = defaults.string(forKey: Keys.lastPostId), + !title.isEmpty, + !postId.isEmpty + else { + return nil + } + + return Snapshot(postId: postId, title: title, date: date) + } +} diff --git a/MacMagazine/WidgetWatch/AppIntent.swift b/MacMagazine/WidgetWatch/AppIntent.swift new file mode 100644 index 00000000..d2680dbf --- /dev/null +++ b/MacMagazine/WidgetWatch/AppIntent.swift @@ -0,0 +1,7 @@ +import AppIntents +import WidgetKit + +struct ConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "MacMagazine" } + static var description: IntentDescription { "Configuração da complicação." } +} diff --git a/MacMagazine/WidgetWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/MacMagazine/WidgetWatch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/MacMagazine/WidgetWatch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MacMagazine/WidgetWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/MacMagazine/WidgetWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..49c81cd8 --- /dev/null +++ b/MacMagazine/WidgetWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MacMagazine/WidgetWatch/Assets.xcassets/Contents.json b/MacMagazine/WidgetWatch/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MacMagazine/WidgetWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MacMagazine/WidgetWatch/Assets.xcassets/WidgetBackground.colorset/Contents.json b/MacMagazine/WidgetWatch/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/MacMagazine/WidgetWatch/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MacMagazine/WidgetWatch/Assets.xcassets/logo_color.imageset/Contents.json b/MacMagazine/WidgetWatch/Assets.xcassets/logo_color.imageset/Contents.json new file mode 100644 index 00000000..085cd9e5 --- /dev/null +++ b/MacMagazine/WidgetWatch/Assets.xcassets/logo_color.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo@3x 3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MacMagazine/WidgetWatch/Assets.xcassets/logo_color.imageset/logo@3x 3.png b/MacMagazine/WidgetWatch/Assets.xcassets/logo_color.imageset/logo@3x 3.png new file mode 100644 index 00000000..63606da9 Binary files /dev/null and b/MacMagazine/WidgetWatch/Assets.xcassets/logo_color.imageset/logo@3x 3.png differ diff --git a/MacMagazine/WidgetWatch/Assets.xcassets/logo_white.imageset/Contents.json b/MacMagazine/WidgetWatch/Assets.xcassets/logo_white.imageset/Contents.json new file mode 100644 index 00000000..7c58c478 --- /dev/null +++ b/MacMagazine/WidgetWatch/Assets.xcassets/logo_white.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MacMagazine/WidgetWatch/Assets.xcassets/logo_white.imageset/logo.png b/MacMagazine/WidgetWatch/Assets.xcassets/logo_white.imageset/logo.png new file mode 100644 index 00000000..24a300db Binary files /dev/null and b/MacMagazine/WidgetWatch/Assets.xcassets/logo_white.imageset/logo.png differ diff --git a/MacMagazine/WidgetWatch/Info.plist b/MacMagazine/WidgetWatch/Info.plist new file mode 100644 index 00000000..0f118fb7 --- /dev/null +++ b/MacMagazine/WidgetWatch/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/MacMagazine/WidgetWatch/WidgetWatch.swift b/MacMagazine/WidgetWatch/WidgetWatch.swift new file mode 100644 index 00000000..43b5d933 --- /dev/null +++ b/MacMagazine/WidgetWatch/WidgetWatch.swift @@ -0,0 +1,301 @@ +import AppIntents +import SwiftUI +import WidgetKit + +// MARK: - Entry + +struct SimpleEntry: TimelineEntry { + let date: Date + let configuration: ConfigurationAppIntent + + let lastPostId: String? + let lastPostTitle: String + let lastPostDate: Date? +} + +// MARK: - Provider + +struct Provider: AppIntentTimelineProvider { + + func recommendations() -> [AppIntentRecommendation] { + [ + AppIntentRecommendation( + intent: ConfigurationAppIntent(), + description: "MacMagazine" + ) + ] + } + + func placeholder(in context: Context) -> SimpleEntry { + SimpleEntry( + date: .now, + configuration: ConfigurationAppIntent(), + lastPostId: UUID().uuidString, + lastPostTitle: "Apple lança atualização do watchOS", + lastPostDate: .now.addingTimeInterval(-60 * 25) + ) + } + + func snapshot( + for configuration: ConfigurationAppIntent, + in context: Context + ) async -> SimpleEntry { + makeEntry(configuration: configuration) + } + + func timeline( + for configuration: ConfigurationAppIntent, + in context: Context + ) async -> Timeline { + + let entry = makeEntry(configuration: configuration) + + let nextUpdate = + Calendar.current.date(byAdding: .minute, value: 30, to: .now) + ?? .now.addingTimeInterval(1800) + + return Timeline(entries: [entry], policy: .after(nextUpdate)) + } + + private func makeEntry(configuration: ConfigurationAppIntent) -> SimpleEntry { + let snap = MacMagazineWidgetSharedStore.readSnapshot() + + return SimpleEntry( + date: .now, + configuration: configuration, + lastPostId: snap?.postId, + lastPostTitle: snap?.title ?? "MacMagazine", + lastPostDate: snap?.date + ) + } +} + +// MARK: - Entry View + +struct WidgetWatchEntryView: View { + + let entry: SimpleEntry + @Environment(\.widgetFamily) private var family + @Environment(\.widgetRenderingMode) private var renderingMode + + var body: some View { + content + .containerBackground(for: .widget) { Color.clear } + } + + @ViewBuilder + private var content: some View { + switch family { + case .accessoryCircular: + circular + case .accessoryCorner: + corner + case .accessoryInline: + inline + case .accessoryRectangular: + rectangular + @unknown default: + EmptyView() + } + } +} + +// MARK: - Layouts + +private extension WidgetWatchEntryView { + + // ACCESSORY CIRCULAR + var circular: some View { + ZStack { + AccessoryWidgetBackground() + + Image("logo_color") + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + } + .widgetURL(URL(string: "macmagazine://news")) + .accessibilityLabel("Abrir MacMagazine") + } + + // ACCESSORY CORNER + var corner: some View { + Image(renderingMode == .fullColor ? "logo_color" : "logo_white") + .resizable() + .renderingMode(renderingMode == .fullColor ? .original : .template) + .scaledToFit() + .frame(width: 35, height: 35) + .foregroundStyle(.primary) + .widgetLabel { + Text(entry.lastPostTitle) + .lineLimit(1) + } + .widgetURL(widgetPostURL()) + .accessibilityElement(children: .ignore) + .accessibilityLabel("MacMagazine") + .accessibilityValue("Última notícia: \(entry.lastPostTitle)") + .accessibilityHint("Toque para abrir as notícias") + } + + // ACCESSORY INLINE + var inline: some View { + Text("\(entry.lastPostTitle)") + .widgetURL(widgetPostURL()) + .accessibilityLabel("MacMagazine") + .accessibilityValue("Última notícia: \(entry.lastPostTitle)") + .accessibilityHint("Toque para abrir as notícias") + } + + // ACCESSORY RECTANGULAR + var rectangular: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(renderingMode == .fullColor ? "logo_color" : "logo_white") + .resizable() + .renderingMode(renderingMode == .fullColor ? .original : .template) + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundStyle(.primary) + + Text(relativePostTimeText(entry.lastPostDate)) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer(minLength: 0) + } + + Text(entry.lastPostTitle) + .font(.callout) + .lineLimit(2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .containerBackground(for: .widget) { AccessoryWidgetBackground() } + .widgetURL(widgetPostURL()) + .accessibilityElement(children: .combine) + .accessibilityLabel("MacMagazine") + .accessibilityValue(accessibilityValueForRectangular()) + .accessibilityHint("Toque para abrir as notícias") + } + + func widgetPostURL() -> URL? { + if let postId = entry.lastPostId, !postId.isEmpty { + return URL(string: "macmagazine://news/post/\(postId)") + } + + return URL(string: "macmagazine://news") + } + + func relativePostTimeText(_ date: Date?) -> String { + guard let date else { return "Atualizado recentemente" } + + let time = Self.timeFormatter.string(from: date) + let calendar = Calendar.current + + if calendar.isDateInToday(date) { + return "Hoje às \(time)" + } + + if calendar.isDateInYesterday(date) { + return "Ontem às \(time)" + } + + return "\(Self.dayFormatter.string(from: date)) às \(time)" + } + + func accessibilityValueForRectangular() -> String { + let whenText = relativePostTimeText(entry.lastPostDate) + return "\(whenText), \(entry.lastPostTitle)" + } + + static let timeFormatter: DateFormatter = { + let formatterDate = DateFormatter() + formatterDate.locale = Locale(identifier: "pt_BR") + formatterDate.dateFormat = "HH:mm" + return formatterDate + }() + + static let dayFormatter: DateFormatter = { + let formatterDate = DateFormatter() + formatterDate.locale = Locale(identifier: "pt_BR") + formatterDate.dateFormat = "dd/MM" + return formatterDate + }() +} + +// MARK: - Widget + +struct WidgetWatch: Widget { + + let kind: String = "WidgetWatch" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: ConfigurationAppIntent.self, + provider: Provider() + ) { entry in + WidgetWatchEntryView(entry: entry) + } + .configurationDisplayName("MacMagazine") + .description("Acesso rápido às notícias do MacMagazine.") + .supportedFamilies([ + .accessoryCircular, + .accessoryCorner, + .accessoryInline, + .accessoryRectangular + ]) + } +} + +#if DEBUG + +#Preview("Circular", as: .accessoryCircular) { + WidgetWatch() +} timeline: { + SimpleEntry( + date: .now, + configuration: ConfigurationAppIntent(), + lastPostId: UUID().uuidString, + lastPostTitle: "Apple lança atualização do watchOS", + lastPostDate: .now.addingTimeInterval(-60 * 25) + ) +} + +#Preview("Corner", as: .accessoryCorner) { + WidgetWatch() +} timeline: { + SimpleEntry( + date: .now, + configuration: ConfigurationAppIntent(), + lastPostId: UUID().uuidString, + lastPostTitle: "Apple lança atualização do watchOS", + lastPostDate: .now.addingTimeInterval(-60 * 25) + ) +} + +#Preview("Inline", as: .accessoryInline) { + WidgetWatch() +} timeline: { + SimpleEntry( + date: .now, + configuration: ConfigurationAppIntent(), + lastPostId: UUID().uuidString, + lastPostTitle: "Apple lança atualização do watchOS", + lastPostDate: .now.addingTimeInterval(-60 * 25) + ) +} + +#Preview("Rectangular", as: .accessoryRectangular) { + WidgetWatch() +} timeline: { + SimpleEntry( + date: .now, + configuration: ConfigurationAppIntent(), + lastPostId: UUID().uuidString, + lastPostTitle: "O melhor pedaço da maçã da internet, clique para ver mais!", + lastPostDate: .now.addingTimeInterval(-60 * 90) + ) +} + +#endif diff --git a/MacMagazine/WidgetWatch/WidgetWatchBundle.swift b/MacMagazine/WidgetWatch/WidgetWatchBundle.swift new file mode 100644 index 00000000..b120048e --- /dev/null +++ b/MacMagazine/WidgetWatch/WidgetWatchBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct WidgetWatchBundle: WidgetBundle { + var body: some Widget { + WidgetWatch() + } +} diff --git a/MacMagazine/WidgetWatchExtension.entitlements b/MacMagazine/WidgetWatchExtension.entitlements new file mode 100644 index 00000000..1f2b1f33 --- /dev/null +++ b/MacMagazine/WidgetWatchExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.brit.beta.macmagazine + + +