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