From 4a7985b6de9709a3706d89330cfc510fa445189b Mon Sep 17 00:00:00 2001 From: Yahor Barkouski Date: Sun, 29 Mar 2026 14:52:16 +0200 Subject: [PATCH] Refine the iOS Day Wrapped widget --- ios/ScreencapMobile/Info.plist | 2 + ios/ScreencapMobile/Sources/AppModel.swift | 9 + ios/ScreencapMobileReport/Info.plist | 2 + ios/ScreencapMobileWidget/Info.plist | 2 + .../Sources/CycleWidgetModeIntent.swift | 11 + .../Sources/DayWrappedWidgetView.swift | 228 ++++++++++++++---- .../Sources/ScreencapMobileWidgetBundle.swift | 31 ++- ios/Shared/AppGroupStore.swift | 15 ++ ios/Shared/AuthStore.swift | 17 +- ios/Shared/Models.swift | 39 +++ 10 files changed, 295 insertions(+), 61 deletions(-) diff --git a/ios/ScreencapMobile/Info.plist b/ios/ScreencapMobile/Info.plist index 6bab88a..79fffb8 100644 --- a/ios/ScreencapMobile/Info.plist +++ b/ios/ScreencapMobile/Info.plist @@ -32,6 +32,8 @@ Screencap uses the camera to scan the pairing QR code from your Mac when you connect your iPhone companion app. NSLocalNetworkUsageDescription Screencap connects to your paired Mac over the local network to refresh Day Wrapped data for the iPhone app and widget. + ScreencapSharedKeychainAccessGroup + $(AppIdentifierPrefix)app.screencap.mobile.shared UIBackgroundModes fetch diff --git a/ios/ScreencapMobile/Sources/AppModel.swift b/ios/ScreencapMobile/Sources/AppModel.swift index d430a30..f588c2c 100644 --- a/ios/ScreencapMobile/Sources/AppModel.swift +++ b/ios/ScreencapMobile/Sources/AppModel.swift @@ -893,6 +893,15 @@ final class AppModel: ObservableObject { uploadStatus = "Debug sample snapshot" errorMessage = nil infoMessage = nil + try? AppGroupStore.saveSnapshot(demoSnapshot) + AppGroupStore.saveUploadStatus( + dayStartMs: demoSnapshot.dayStartMs, + message: "Debug sample snapshot" + ) + AppGroupStore.saveWidgetSelectedDayStartMs(demoSnapshot.dayStartMs) + AppGroupStore.saveWidgetMode(.categories) + AppGroupStore.saveWidgetSourceFilter(.both) + WidgetCenter.shared.reloadAllTimelines() } #endif } diff --git a/ios/ScreencapMobileReport/Info.plist b/ios/ScreencapMobileReport/Info.plist index c055073..377db82 100644 --- a/ios/ScreencapMobileReport/Info.plist +++ b/ios/ScreencapMobileReport/Info.plist @@ -18,6 +18,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + ScreencapSharedKeychainAccessGroup + $(AppIdentifierPrefix)app.screencap.mobile.shared EXAppExtensionAttributes EXExtensionPointIdentifier diff --git a/ios/ScreencapMobileWidget/Info.plist b/ios/ScreencapMobileWidget/Info.plist index 75945da..d990585 100644 --- a/ios/ScreencapMobileWidget/Info.plist +++ b/ios/ScreencapMobileWidget/Info.plist @@ -20,6 +20,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + ScreencapSharedKeychainAccessGroup + $(AppIdentifierPrefix)app.screencap.mobile.shared NSExtension NSExtensionPointIdentifier diff --git a/ios/ScreencapMobileWidget/Sources/CycleWidgetModeIntent.swift b/ios/ScreencapMobileWidget/Sources/CycleWidgetModeIntent.swift index f4270c9..fd892b2 100644 --- a/ios/ScreencapMobileWidget/Sources/CycleWidgetModeIntent.swift +++ b/ios/ScreencapMobileWidget/Sources/CycleWidgetModeIntent.swift @@ -11,3 +11,14 @@ struct CycleWidgetModeIntent: AppIntent { return .result() } } + +struct CycleWidgetSourceFilterIntent: AppIntent { + static var title: LocalizedStringResource = "Cycle Day Wrapped Source Filter" + + func perform() async throws -> some IntentResult { + let nextFilter = AppGroupStore.loadWidgetSourceFilter().nextWidgetSourceFilter + AppGroupStore.saveWidgetSourceFilter(nextFilter) + WidgetCenter.shared.reloadTimelines(ofKind: "ScreencapMobileWidget") + return .result() + } +} diff --git a/ios/ScreencapMobileWidget/Sources/DayWrappedWidgetView.swift b/ios/ScreencapMobileWidget/Sources/DayWrappedWidgetView.swift index da6493a..5fb7d25 100644 --- a/ios/ScreencapMobileWidget/Sources/DayWrappedWidgetView.swift +++ b/ios/ScreencapMobileWidget/Sources/DayWrappedWidgetView.swift @@ -7,6 +7,7 @@ struct DayWrappedWidgetView: View { let snapshot: DayWrappedSnapshot? let mode: WrappedMode + let sourceFilter: WrappedSourceFilter private var metrics: WidgetMetrics { WidgetMetrics.resolve(for: widgetFamily) @@ -19,10 +20,10 @@ struct DayWrappedWidgetView: View { title: base.title, subtitle: base.subtitle, updatedAtMs: base.updatedAtMs, - sourceSummary: base.sourceSummary, - pairedDeviceName: base.pairedDeviceName, + sourceSummary: displayedSourceFilter.label, + pairedDeviceName: displayedSourceFilter == .iphone ? (base.pairedDeviceName ?? "iPhone") : nil, mode: displayedMode, - slots: base.slots + slots: base.slots.map(filteredSlot(for:)) ) } @@ -30,6 +31,10 @@ struct DayWrappedWidgetView: View { metrics.showsModeSwitcher ? mode : .categories } + private var displayedSourceFilter: WrappedSourceFilter { + metrics.showsSourceFilterSwitcher ? sourceFilter : .both + } + private var orderedSlots: [WrappedSlot] { var result: [WrappedSlot] = [] result.reserveCapacity(resolvedSnapshot.slots.count) @@ -46,6 +51,18 @@ struct DayWrappedWidgetView: View { DayWrappedRendering.timeMarkers(for: resolvedSnapshot) } + private var categories: [WrappedCategory] { + let legend = DayWrappedRendering.legendCategories( + for: resolvedSnapshot, + limit: metrics.categoryLegendLimit + ) + return legend.isEmpty ? [.unknown] : legend + } + + private var categoryLegendFillOpacity: Double { + 0.46 + } + private var gridColumns: [GridItem] { Array( repeating: GridItem(.flexible(minimum: 0, maximum: .infinity), spacing: metrics.gridGap), @@ -63,6 +80,15 @@ struct DayWrappedWidgetView: View { ) } + private var headerTitleText: String { + let formatter = DateFormatter() + formatter.locale = .current + formatter.dateFormat = "EEEE, MMMM d" + return formatter.string( + from: Date(timeIntervalSince1970: TimeInterval(resolvedSnapshot.dayStartMs) / 1000) + ) + } + var body: some View { VStack(alignment: .leading, spacing: metrics.sectionSpacing) { header @@ -78,9 +104,9 @@ struct DayWrappedWidgetView: View { private var header: some View { HStack(alignment: .top, spacing: 10) { VStack(alignment: .leading, spacing: metrics.headerTextSpacing) { - Text(resolvedSnapshot.subtitle) + Text(headerTitleText) .font(.system(size: metrics.titleFontSize, weight: .semibold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(Color.white.opacity(0.86)) .lineLimit(1) .minimumScaleFactor(0.72) @@ -89,12 +115,21 @@ struct DayWrappedWidgetView: View { .font(.system(size: 11, weight: .medium, design: .rounded)) .foregroundStyle(.white.opacity(0.48)) .lineLimit(1) - } + } } + .padding(.top, metrics.headerTitleTopPadding) Spacer(minLength: 8) HStack(spacing: 6) { + if metrics.showsSourceFilterSwitcher { + Button(intent: CycleWidgetSourceFilterIntent()) { + Image(systemName: displayedSourceFilter.iconName) + } + .buttonStyle(.plain) + .widgetControlStyle(size: metrics.toggleHeight) + } + Button(intent: PreviousWidgetDayIntent()) { Image(systemName: "chevron.left") } @@ -142,7 +177,7 @@ struct DayWrappedWidgetView: View { RoundedRectangle(cornerRadius: metrics.cellCornerRadius, style: .continuous) .fill(slotFill(for: slot)) .overlay { - if let accent = sourceAccentColor(for: slot.source) { + if metrics.showsSourceAccent, let accent = sourceAccentColor(for: slot.source) { RoundedRectangle(cornerRadius: metrics.cellCornerRadius, style: .continuous) .stroke(accent, lineWidth: metrics.accentLineWidth) } @@ -162,43 +197,64 @@ struct DayWrappedWidgetView: View { } } } + .padding(.top, metrics.gridTopPadding) } private var footer: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 8) { - if let pairedDeviceName = resolvedSnapshot.pairedDeviceName, !pairedDeviceName.isEmpty { - WidgetBadge( - text: pairedDeviceName, - icon: "iphone.gen3", - tint: WidgetPalette.iphoneAccent - ) + Group { + if metrics.showsCategoryLegend { + HStack { + Spacer(minLength: 0) + HStack(spacing: metrics.categoryLegendSpacing) { + ForEach(categories, id: \.self) { category in + WidgetLegendKey( + text: category.rawValue, + fill: category.color.opacity(categoryLegendFillOpacity), + fontSize: metrics.categoryLegendFontSize, + dotSize: metrics.categoryLegendDotSize + ) + } + } + Spacer(minLength: 0) } + .padding(.horizontal, metrics.categoryLegendHorizontalPadding) + } else { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + if let pairedDeviceName = resolvedSnapshot.pairedDeviceName, !pairedDeviceName.isEmpty { + WidgetBadge( + text: pairedDeviceName, + icon: "iphone.gen3", + tint: WidgetPalette.iphoneAccent + ) + } - if hasIphoneSourceAccent { - WidgetBadge( - text: "Synced", - icon: "arrow.triangle.2.circlepath", - tint: WidgetPalette.bothAccent - ) - } - } + if hasIphoneSourceAccent { + WidgetBadge( + text: "Synced", + icon: "arrow.triangle.2.circlepath", + tint: WidgetPalette.bothAccent + ) + } + } - VStack(alignment: .leading, spacing: 6) { - if let pairedDeviceName = resolvedSnapshot.pairedDeviceName, !pairedDeviceName.isEmpty { - WidgetBadge( - text: pairedDeviceName, - icon: "iphone.gen3", - tint: WidgetPalette.iphoneAccent - ) - } + VStack(alignment: .leading, spacing: 6) { + if let pairedDeviceName = resolvedSnapshot.pairedDeviceName, !pairedDeviceName.isEmpty { + WidgetBadge( + text: pairedDeviceName, + icon: "iphone.gen3", + tint: WidgetPalette.iphoneAccent + ) + } - if hasIphoneSourceAccent { - WidgetBadge( - text: "Synced", - icon: "arrow.triangle.2.circlepath", - tint: WidgetPalette.bothAccent - ) + if hasIphoneSourceAccent { + WidgetBadge( + text: "Synced", + icon: "arrow.triangle.2.circlepath", + tint: WidgetPalette.bothAccent + ) + } + } } } } @@ -210,6 +266,37 @@ struct DayWrappedWidgetView: View { : Color.white.opacity(0.05) } + private func filteredSlot(for slot: WrappedSlot) -> WrappedSlot { + switch displayedSourceFilter { + case .both: + return slot + case .mac: + let count = slot.macCount + return WrappedSlot( + id: slot.id, + startMs: slot.startMs, + count: count, + category: slot.category, + appName: slot.appName, + source: count > 0 ? .mac : .none, + macCount: count, + iphoneCount: 0 + ) + case .iphone: + let count = slot.iphoneCount + return WrappedSlot( + id: slot.id, + startMs: slot.startMs, + count: count, + category: slot.category, + appName: slot.appName, + source: count > 0 ? .iphone : .none, + macCount: 0, + iphoneCount: count + ) + } + } + private func sourceAccentColor(for source: WrappedSourceAccent) -> Color? { switch source { case .iphone: @@ -283,12 +370,33 @@ private struct WidgetBadge: View { } } +private struct WidgetLegendKey: View { + let text: String + let fill: Color + let fontSize: CGFloat + let dotSize: CGFloat + + var body: some View { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: dotSize * 0.35, style: .continuous) + .fill(fill) + .frame(width: dotSize, height: dotSize) + + Text(text) + .font(.system(size: fontSize, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.46)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } +} + private extension View { func widgetControlStyle(size: CGFloat) -> some View { font(.system(size: 12, weight: .semibold)) .frame(width: size, height: size) .background( - RoundedRectangle(cornerRadius: size * 0.4, style: .continuous) + Circle() .fill(Color.white.opacity(0.08)) ) .foregroundStyle(.white.opacity(0.88)) @@ -299,6 +407,8 @@ private struct WidgetMetrics { let outerPadding: CGFloat let sectionSpacing: CGFloat let headerTextSpacing: CGFloat + let headerTitleTopPadding: CGFloat + let gridTopPadding: CGFloat let gridSectionSpacing: CGFloat let gridGap: CGFloat let cellCornerRadius: CGFloat @@ -309,6 +419,14 @@ private struct WidgetMetrics { let toggleHorizontalPadding: CGFloat let toggleIconSize: CGFloat let toggleFontSize: CGFloat + let showsSourceFilterSwitcher: Bool + let showsSourceAccent: Bool + let showsCategoryLegend: Bool + let categoryLegendLimit: Int + let categoryLegendSpacing: CGFloat + let categoryLegendHorizontalPadding: CGFloat + let categoryLegendFontSize: CGFloat + let categoryLegendDotSize: CGFloat let showsModeSwitcher: Bool let showsModeLabel: Bool let showsSourceSummary: Bool @@ -318,29 +436,41 @@ private struct WidgetMetrics { switch family { case .systemMedium: return WidgetMetrics( - outerPadding: 12, - sectionSpacing: 7, + outerPadding: 14, + sectionSpacing: 6, headerTextSpacing: 1, - gridSectionSpacing: 4, - gridGap: 2, - cellCornerRadius: 3, + headerTitleTopPadding: 4, + gridTopPadding: 6, + gridSectionSpacing: 3, + gridGap: 1.5, + cellCornerRadius: 2.5, accentLineWidth: 0.8, - titleFontSize: 15, + titleFontSize: 18, timeFontSize: 7, toggleHeight: 22, toggleHorizontalPadding: 7, toggleIconSize: 9, toggleFontSize: 9, + showsSourceFilterSwitcher: true, + showsSourceAccent: false, + showsCategoryLegend: true, + categoryLegendLimit: 3, + categoryLegendSpacing: 14, + categoryLegendHorizontalPadding: 6, + categoryLegendFontSize: 11, + categoryLegendDotSize: 11, showsModeSwitcher: false, showsModeLabel: false, showsSourceSummary: false, - showsFooter: false + showsFooter: true ) default: return WidgetMetrics( outerPadding: 18, sectionSpacing: 14, headerTextSpacing: 4, + headerTitleTopPadding: 0, + gridTopPadding: 0, gridSectionSpacing: 8, gridGap: 3, cellCornerRadius: 3.5, @@ -351,6 +481,14 @@ private struct WidgetMetrics { toggleHorizontalPadding: 10, toggleIconSize: 10, toggleFontSize: 11, + showsSourceFilterSwitcher: false, + showsSourceAccent: true, + showsCategoryLegend: false, + categoryLegendLimit: 4, + categoryLegendSpacing: 18, + categoryLegendHorizontalPadding: 0, + categoryLegendFontSize: 11, + categoryLegendDotSize: 10, showsModeSwitcher: true, showsModeLabel: true, showsSourceSummary: true, diff --git a/ios/ScreencapMobileWidget/Sources/ScreencapMobileWidgetBundle.swift b/ios/ScreencapMobileWidget/Sources/ScreencapMobileWidgetBundle.swift index 15d02c6..1a76f25 100644 --- a/ios/ScreencapMobileWidget/Sources/ScreencapMobileWidgetBundle.swift +++ b/ios/ScreencapMobileWidget/Sources/ScreencapMobileWidgetBundle.swift @@ -5,26 +5,44 @@ struct ScreencapWidgetEntry: TimelineEntry { let date: Date let snapshot: DayWrappedSnapshot? let mode: WrappedMode + let sourceFilter: WrappedSourceFilter let isPlaceholder: Bool } struct ScreencapWidgetProvider: TimelineProvider { + private func resolvedSnapshot(preview: Bool) -> DayWrappedSnapshot? { + if preview { + return sampleSnapshot() + } + + if let snapshot = AppGroupStore.loadWidgetSnapshot() { + return snapshot + } + + #if DEBUG + return sampleSnapshot() + #else + return nil + #endif + } + func placeholder(in _: Context) -> ScreencapWidgetEntry { ScreencapWidgetEntry( date: Date(), snapshot: sampleSnapshot(), mode: .categories, + sourceFilter: .both, isPlaceholder: true ) } func getSnapshot(in context: Context, completion: @escaping (ScreencapWidgetEntry) -> Void) { - let snapshot = context.isPreview ? sampleSnapshot() : AppGroupStore.loadWidgetSnapshot() completion( ScreencapWidgetEntry( date: Date(), - snapshot: snapshot, + snapshot: resolvedSnapshot(preview: context.isPreview), mode: AppGroupStore.loadWidgetMode(), + sourceFilter: AppGroupStore.loadWidgetSourceFilter(), isPlaceholder: context.isPreview ) ) @@ -33,8 +51,9 @@ struct ScreencapWidgetProvider: TimelineProvider { func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { let entry = ScreencapWidgetEntry( date: Date(), - snapshot: AppGroupStore.loadWidgetSnapshot(), + snapshot: resolvedSnapshot(preview: false), mode: AppGroupStore.loadWidgetMode(), + sourceFilter: AppGroupStore.loadWidgetSourceFilter(), isPlaceholder: false ) completion(Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(30 * 60)))) @@ -48,7 +67,11 @@ struct ScreencapWidgetProvider: TimelineProvider { struct ScreencapMobileWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "ScreencapMobileWidget", provider: ScreencapWidgetProvider()) { entry in - DayWrappedWidgetView(snapshot: entry.snapshot, mode: entry.mode) + DayWrappedWidgetView( + snapshot: entry.snapshot, + mode: entry.mode, + sourceFilter: entry.sourceFilter + ) .containerBackground(for: .widget) { LinearGradient( colors: [ diff --git a/ios/Shared/AppGroupStore.swift b/ios/Shared/AppGroupStore.swift index 1c62cd0..7089629 100644 --- a/ios/Shared/AppGroupStore.swift +++ b/ios/Shared/AppGroupStore.swift @@ -8,6 +8,7 @@ enum AppGroupStore { private static let reportDayKey = "report.dayStartMs" private static let uploadStatusKeyPrefix = "upload.status." private static let widgetModeKey = "widget.dayWrappedMode" + private static let widgetSourceFilterKey = "widget.dayWrappedSourceFilter" private static let widgetDayKey = "widget.dayWrappedDayStartMs" private static let diagnosticsKey = "sync.diagnostics.v1" @@ -182,6 +183,20 @@ enum AppGroupStore { return .categories } + static func saveWidgetSourceFilter(_ filter: WrappedSourceFilter) { + defaults.set(filter.rawValue, forKey: widgetSourceFilterKey) + } + + static func loadWidgetSourceFilter() -> WrappedSourceFilter { + if + let rawValue = defaults.string(forKey: widgetSourceFilterKey), + let filter = WrappedSourceFilter(rawValue: rawValue) + { + return filter + } + return .both + } + static func loadDiagnostics() -> SyncDiagnostics { guard let data = defaults.data(forKey: diagnosticsKey) else { return SyncDiagnostics() diff --git a/ios/Shared/AuthStore.swift b/ios/Shared/AuthStore.swift index 0f28f05..762f029 100644 --- a/ios/Shared/AuthStore.swift +++ b/ios/Shared/AuthStore.swift @@ -7,7 +7,7 @@ enum AuthStore { private static let signKeyAccount = "sign-private-raw" private static let dhKeyAccount = "dh-private-raw" private static let sharedAccessGroupSuffix = ".app.screencap.mobile.shared" - private static let keychainAccessGroupsEntitlement = "keychain-access-groups" + private static let sharedAccessGroupInfoKey = "ScreencapSharedKeychainAccessGroup" private static var cachedSharedAccessGroup: String? enum SignedRequestCredentials { @@ -143,21 +143,14 @@ enum AuthStore { if let cachedSharedAccessGroup { return cachedSharedAccessGroup } - guard let task = SecTaskCreateFromSelf(nil) else { - return nil - } + guard - let value = SecTaskCopyValueForEntitlement( - task, - keychainAccessGroupsEntitlement as CFString, - nil - ) as? [String] + let accessGroup = Bundle.main.object(forInfoDictionaryKey: sharedAccessGroupInfoKey) + as? String, + accessGroup.hasSuffix(sharedAccessGroupSuffix) else { return nil } - let accessGroup = - value.first(where: { $0.hasSuffix(sharedAccessGroupSuffix) }) - ?? value.first cachedSharedAccessGroup = accessGroup return accessGroup } diff --git a/ios/Shared/Models.swift b/ios/Shared/Models.swift index d327536..51b4df9 100644 --- a/ios/Shared/Models.swift +++ b/ios/Shared/Models.swift @@ -15,6 +15,45 @@ enum WrappedMode: String, Codable, CaseIterable, Sendable { } } +enum WrappedSourceFilter: String, Codable, CaseIterable, Sendable { + case both + case mac + case iphone + + var nextWidgetSourceFilter: WrappedSourceFilter { + switch self { + case .both: + return .mac + case .mac: + return .iphone + case .iphone: + return .both + } + } + + var label: String { + switch self { + case .both: + return "Both" + case .mac: + return "Mac" + case .iphone: + return "iPhone" + } + } + + var iconName: String { + switch self { + case .both: + return "rectangle.on.rectangle" + case .mac: + return "desktopcomputer" + case .iphone: + return "iphone.gen3" + } + } +} + enum WrappedSourceAccent: String, Codable, Hashable, Sendable { case none case mac