diff --git a/AltWidget/Extensions/View+AltWidget.swift b/AltWidget/Extensions/View+AltWidget.swift index ce04c510..06ec070e 100644 --- a/AltWidget/Extensions/View+AltWidget.swift +++ b/AltWidget/Extensions/View+AltWidget.swift @@ -7,6 +7,7 @@ // import SwiftUI +import WidgetKit extension View { @@ -78,4 +79,59 @@ extension View } } + /// Opts this view into the widget accent group on iOS 16+, which lets the + /// system tint it with the user's chosen colour in tinted (accented) mode. + /// No-op on older OS versions where the API does not exist. + @ViewBuilder + func widgetAccentableIfAvailable() -> some View + { + if #available(iOSApplicationExtension 16, *) + { + self.widgetAccentable() + } + else + { + self + } + } + + /// Applies `luminanceToAlpha()` only when the widget is rendering in + /// accented (tinted) mode on iOS 16+. This converts the view's pixel + /// brightness into opacity so the system can overlay the user's chosen + /// tint colour correctly — without it, images appear as white rectangles + /// in tinted mode. No-op in fullColor/dark/light mode and on older OS. + @ViewBuilder + func luminanceToAlphaInAccentedMode() -> some View + { + if #available(iOSApplicationExtension 16, *) + { + LuminanceToAlphaWrapper(content: self) + } + else + { + self + } + } + +} + +/// Helper view that reads widgetRenderingMode (iOS 16+) and conditionally +/// applies luminanceToAlpha(). Kept separate so the environment read is +/// cleanly scoped behind the @available gate. +@available(iOSApplicationExtension 16, *) +private struct LuminanceToAlphaWrapper: View +{ + let content: Content + @Environment(\.widgetRenderingMode) private var renderingMode + + var body: some View { + if renderingMode == .accented + { + content.luminanceToAlpha() + } + else + { + content + } + } } diff --git a/AltWidget/Intents/ViewAppIntent.swift b/AltWidget/Intents/ViewAppIntent.swift new file mode 100644 index 00000000..5d46eb1e --- /dev/null +++ b/AltWidget/Intents/ViewAppIntent.swift @@ -0,0 +1,73 @@ +// +// ViewAppIntent.swift +// AltWidgetExtension +// +// Replaces the legacy SiriKit ViewAppIntent (ViewApp.intentdefinition) with a +// modern AppIntents-based intent. Required because IntentConfiguration does not +// support containerBackground on iOS 17+, causing the blank-widget bug. +// + +import AppIntents +import WidgetKit +import AltStoreCore + +// Represents one installed app in the picker list. +@available(iOSApplicationExtension 17, *) +struct InstalledAppEntity: AppEntity +{ + // Disambiguates from the AppEntity name used in AppIntents framework. + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Installed App" + static var defaultQuery = InstalledAppQuery() + + var id: String // bundle identifier + var name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +@available(iOSApplicationExtension 17, *) +struct InstalledAppQuery: EntityQuery +{ + func entities(for identifiers: [String]) async throws -> [InstalledAppEntity] + { + try await DatabaseManager.shared.start() + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + return try await context.performAsync { + let fetchRequest = InstalledApp.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "%K IN %@", + #keyPath(InstalledApp.bundleIdentifier), + identifiers + ) + fetchRequest.returnsObjectsAsFaults = false + let apps = try context.fetch(fetchRequest) + return apps.map { InstalledAppEntity(id: $0.bundleIdentifier, name: $0.name) } + } + } + + func suggestedEntities() async throws -> [InstalledAppEntity] + { + try await DatabaseManager.shared.start() + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + return try await context.performAsync { + InstalledApp.all(in: context) + .map { InstalledAppEntity(id: $0.bundleIdentifier, name: $0.name) } + .sorted { $0.name < $1.name } + } + } +} + +@available(iOSApplicationExtension 17, *) +struct SelectAppIntent: WidgetConfigurationIntent +{ + static var title: LocalizedStringResource = "Select App" + static var description = IntentDescription("Choose which app to display.") + + @Parameter(title: "App") + var app: InstalledAppEntity? + + // WidgetConfigurationIntent requires perform() — no-op for configuration intents. + func perform() async throws -> some IntentResult { .result() } +} diff --git a/AltWidget/Providers/AppsTimelineProvider.swift b/AltWidget/Providers/AppsTimelineProvider.swift index 5eded4c3..caa98a55 100644 --- a/AltWidget/Providers/AppsTimelineProvider.swift +++ b/AltWidget/Providers/AppsTimelineProvider.swift @@ -221,3 +221,33 @@ class AppsTimelineProvider: AppsTimelineProviderBase, IntentTimelineProv } } } + +// Modern AppIntents-based provider for AppDetailWidget on iOS 17+. +// Replaces AppsTimelineProvider (IntentTimelineProvider) which uses the legacy +// SiriKit Intents framework that breaks containerBackground on iOS 17+. +@available(iOSApplicationExtension 17, *) +class SelectAppTimelineProvider: AppsTimelineProviderBase, AppIntentTimelineProvider +{ + typealias Intent = SelectAppIntent + + func snapshot(for intent: SelectAppIntent, in context: Context) async -> AppsEntry + { + let bundleID = await resolvedBundleID(for: intent) + return await self.snapshot(for: [bundleID], in: intent) + } + + func timeline(for intent: SelectAppIntent, in context: Context) async -> Timeline> + { + let bundleID = await resolvedBundleID(for: intent) + return await self.timeline(for: [bundleID], in: intent) + } + + // If the user hasn't picked an app yet, fall back to the first active app + // rather than a hardcoded bundle ID that may not exist in the database. + private func resolvedBundleID(for intent: SelectAppIntent) async -> String + { + if let id = intent.app?.id { return id } + let activeIDs = await self.fetchActiveAppBundleIDs() + return activeIDs.first ?? StoreApp.altstoreAppID + } +} diff --git a/AltWidget/Widgets/ActiveAppsWidget.swift b/AltWidget/Widgets/ActiveAppsWidget.swift index 3e6d6ebb..868f5210 100644 --- a/AltWidget/Widgets/ActiveAppsWidget.swift +++ b/AltWidget/Widgets/ActiveAppsWidget.swift @@ -74,6 +74,9 @@ private struct ActiveAppsWidgetView: View @Environment(\.colorScheme) private var colorScheme + + @Environment(\.widgetRenderingMode) + private var renderingMode var body: some View { Group { @@ -92,6 +95,12 @@ private struct ActiveAppsWidgetView: View { LinearGradient(colors: [.altGradientDark, .altGradientExtraDark], startPoint: .top, endPoint: .bottom) } + else if renderingMode == .accented + { + // Plain dark background in tinted mode so the system's + // accent colour composites cleanly over it. + Color.black + } else { LinearGradient(colors: [.altGradientLight, .altGradientDark], startPoint: .top, endPoint: .bottom) @@ -128,11 +137,16 @@ private struct ActiveAppsWidgetView: View let daysRemaining = app.expirationDate.numberOfCalendarDays(since: entry.date) HStack(spacing: 10) { + // In tinted (accented) mode, luminanceToAlpha() converts the icon's + // brightness into opacity so the system can tint it with the user's + // chosen accent colour. widgetAccentable() opts the view into that + // accent group. In fullColor mode both are no-ops (via the helpers). Image(uiImage: resizedIcon) .resizable() .aspectRatio(contentMode: .fit) - .cornerRadius(cornerRadius) - + .luminanceToAlphaInAccentedMode() + .mask(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .widgetAccentableIfAvailable() VStack(alignment: .leading, spacing: 1) { Text(app.name) @@ -151,6 +165,7 @@ private struct ActiveAppsWidgetView: View .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) } + .widgetAccentableIfAvailable() Spacer() @@ -167,6 +182,7 @@ private struct ActiveAppsWidgetView: View .activatesRefreshAllAppsIntent() // this modifier invalidates the view (disables user interaction and shows a blinking effect) .invalidatableContent() + .widgetAccentableIfAvailable() } .frame(height: rowHeight) diff --git a/AltWidget/Widgets/AppDetailWidget.swift b/AltWidget/Widgets/AppDetailWidget.swift index 008d6f80..01a0d2d6 100644 --- a/AltWidget/Widgets/AppDetailWidget.swift +++ b/AltWidget/Widgets/AppDetailWidget.swift @@ -15,36 +15,52 @@ struct AppDetailWidget: Widget private let kind: String = "AppDetail" public var body: some WidgetConfiguration { - let configuration = IntentConfiguration(kind: kind, - intent: ViewAppIntent.self, - provider: AppsTimelineProvider()) { (entry) in - AppDetailWidgetView(entry: entry) - } - .supportedFamilies([.systemSmall]) - .configurationDisplayName("App Status") - .description("View remaining days until your sideloaded apps expire. Tap the countdown timer to refresh them in the background.") - - if #available(iOS 17, *) + // On iOS 16+ use AppIntentConfiguration — it correctly supports + // containerBackground and contentMarginsDisabled(), unlike the legacy + // IntentConfiguration which breaks on iOS 17+ with the + // "Please adopt containerBackground" error. + if #available(iOSApplicationExtension 17, *) { - return configuration - .contentMarginsDisabled() + return AppIntentConfiguration( + kind: kind, + intent: SelectAppIntent.self, + provider: SelectAppTimelineProvider() + ) { entry in + AppDetailWidgetView(apps: entry.apps, date: entry.date, isPlaceholder: entry.isPlaceholder) + } + .supportedFamilies([.systemSmall]) + .configurationDisplayName("App Status") + .description("View remaining days until your sideloaded apps expire. Tap the countdown timer to refresh them in the background.") + .contentMarginsDisabled() } else { - return configuration + // Legacy path for iOS 15. + return IntentConfiguration( + kind: kind, + intent: ViewAppIntent.self, + provider: AppsTimelineProvider() + ) { entry in + AppDetailWidgetView(apps: entry.apps, date: entry.date, isPlaceholder: entry.isPlaceholder) + } + .supportedFamilies([.systemSmall]) + .configurationDisplayName("App Status") + .description("View remaining days until your sideloaded apps expire. Tap the countdown timer to refresh them in the background.") } } } private struct AppDetailWidgetView: View { - var entry: AppsEntry + let apps: [AppSnapshot] + let date: Date + let isPlaceholder: Bool var body: some View { Group { - if let app = self.entry.apps.first + if let app = apps.first { - let daysRemaining = app.expirationDate.numberOfCalendarDays(since: self.entry.date) + let daysRemaining = app.expirationDate.numberOfCalendarDays(since: date) GeometryReader { (geometry) in Group { @@ -52,11 +68,7 @@ private struct AppDetailWidgetView: View VStack(alignment: .leading, spacing: 5) { let imageHeight = geometry.size.height * 0.4 - Image(uiImage: app.icon ?? UIImage()) - .resizable() - .aspectRatio(CGSize(width: 1, height: 1), contentMode: .fit) - .frame(height: imageHeight) - .mask(RoundedRectangle(cornerRadius: imageHeight / 5.0, style: .continuous)) + AppIconView(icon: app.icon, imageHeight: imageHeight) Text(app.name.uppercased()) .font(.system(size: 12, weight: .semibold, design: .rounded)) @@ -65,6 +77,7 @@ private struct AppDetailWidgetView: View .minimumScaleFactor(0.5) } .fixedSize(horizontal: false, vertical: true) + .widgetAccentableIfAvailable() Spacer(minLength: 0) @@ -97,7 +110,7 @@ private struct AppDetailWidgetView: View { Countdown(startDate: app.refreshedDate, endDate: app.expirationDate, - currentDate: self.entry.date) + currentDate: date) .font(.system(size: 20, weight: .semibold, design: .rounded)) .foregroundColor(Color.white) .opacity(0.8) @@ -108,6 +121,7 @@ private struct AppDetailWidgetView: View } .fixedSize(horizontal: false, vertical: true) .activatesRefreshAllAppsIntent() + .widgetAccentableIfAvailable() } .padding() } @@ -118,7 +132,7 @@ private struct AppDetailWidgetView: View VStack { // Put conditional inside VStack, or else an empty view will be returned // if isPlaceholder == false, which messes up layout. - if !entry.isPlaceholder + if !isPlaceholder { Text("App Not Found") .font(.system(.body, design: .rounded)) @@ -131,15 +145,12 @@ private struct AppDetailWidgetView: View } .widgetBackground( backgroundView( - icon: entry.apps.first?.icon, - tintColor: entry.apps.first?.tintColor + icon: apps.first?.icon, + tintColor: apps.first?.tintColor ) ) } -} -private extension AppDetailWidgetView -{ func backgroundView(icon: UIImage? = nil, tintColor: UIColor? = nil) -> some View { let icon = icon ?? UIImage(named: "SideStore")! @@ -173,12 +184,6 @@ private extension AppDetailWidgetView .saturation(saturation) .blur(radius: blurRadius, opaque: true) .scaleEffect(geometry.size.width / imageHeight, anchor: .center) - // .onAppear { - // print("Geometry size: \(geometry.size)") - // print("Image height: \(imageHeight), Geometry width: \(geometry.size.width)") - // print("Icon size: \(icon.size)") - // } - Color(tintColor) .opacity(tintOpacity) @@ -193,6 +198,26 @@ private extension AppDetailWidgetView } } +// In tinted/clear mode: luminanceToAlpha converts pixel brightness → opacity so +// the system can overlay the accent colour. Must come BEFORE the mask so the +// squircle corners are clipped after conversion (reverse order = corner bleed). +// widgetAccentable() opts the result into the accent group. +private struct AppIconView: View +{ + let icon: UIImage? + let imageHeight: CGFloat + + var body: some View { + Image(uiImage: icon ?? UIImage()) + .resizable() + .aspectRatio(CGSize(width: 1, height: 1), contentMode: .fit) + .frame(height: imageHeight) + .luminanceToAlphaInAccentedMode() + .mask(RoundedRectangle(cornerRadius: imageHeight / 5.0, style: .continuous)) + .widgetAccentableIfAvailable() + } +} + @available(iOS 17, *) #Preview(as: .systemSmall) { AppDetailWidget()