[AltWidget] Adds interactive Active Apps widget to view + refresh all active apps (iOS 17+)

This commit is contained in:
Riley Testut
2023-09-01 13:27:28 -05:00
committed by Magesh K
parent 3f6688523a
commit ea2600aba9
16 changed files with 497 additions and 7 deletions

View File

@@ -17,5 +17,10 @@ struct AltWidgetBundle: WidgetBundle
IconLockScreenWidget()
TextLockScreenWidget()
if #available(iOS 17, *)
{
ActiveAppsWidget()
}
}
}

View File

@@ -0,0 +1,202 @@
//
// AppsTimelineProvider.swift
// AltWidgetExtension
//
// Created by Riley Testut on 8/23/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import WidgetKit
import CoreData
import AltStoreCore
struct AppsEntry: TimelineEntry
{
var date: Date
var relevance: TimelineEntryRelevance?
var apps: [AppSnapshot]
var isPlaceholder: Bool = false
}
struct AppsTimelineProvider
{
typealias Entry = AppsEntry
func placeholder(in context: TimelineProviderContext) -> AppsEntry
{
return AppsEntry(date: Date(), apps: [], isPlaceholder: true)
}
func snapshot(for appBundleIDs: [String]) async -> AppsEntry
{
do
{
try await self.prepare()
let apps = try await self.fetchApps(withBundleIDs: appBundleIDs)
let entry = AppsEntry(date: Date(), apps: apps)
return entry
}
catch
{
print("Failed to prepare widget snapshot:", error)
let entry = AppsEntry(date: Date(), apps: [])
return entry
}
}
func timeline(for appBundleIDs: [String]) async -> Timeline<AppsEntry>
{
do
{
try await self.prepare()
let apps = try await self.fetchApps(withBundleIDs: appBundleIDs)
let entries = self.makeEntries(for: apps)
let timeline = Timeline(entries: entries, policy: .atEnd)
return timeline
}
catch
{
print("Failed to prepare widget timeline:", error)
let entry = AppsEntry(date: Date(), apps: [])
let timeline = Timeline(entries: [entry], policy: .atEnd)
return timeline
}
}
}
private extension AppsTimelineProvider
{
func prepare() async throws
{
try await DatabaseManager.shared.start()
}
func fetchApps(withBundleIDs bundleIDs: [String]) async throws -> [AppSnapshot]
{
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
let apps = try await context.performAsync {
let fetchRequest = InstalledApp.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "%K IN %@", #keyPath(InstalledApp.bundleIdentifier), bundleIDs)
fetchRequest.returnsObjectsAsFaults = false
let installedApps = try context.fetch(fetchRequest)
let apps = installedApps.map { AppSnapshot(installedApp: $0) }
// Always list apps in alphabetical order.
let sortedApps = apps.sorted { $0.name < $1.name }
return sortedApps
}
return apps
}
func makeEntries(for snapshots: [AppSnapshot]) -> [AppsEntry]
{
let sortedAppsByExpirationDate = snapshots.sorted { $0.expirationDate < $1.expirationDate }
guard let firstExpiringApp = sortedAppsByExpirationDate.first, let lastExpiringApp = sortedAppsByExpirationDate.last else { return [] }
let currentDate = Calendar.current.startOfDay(for: Date())
let numberOfDays = lastExpiringApp.expirationDate.numberOfCalendarDays(since: currentDate)
// Generate a timeline consisting of one entry per day.
var entries: [AppsEntry] = []
switch numberOfDays
{
case ..<0:
let entry = AppsEntry(date: currentDate, relevance: TimelineEntryRelevance(score: 0.0), apps: snapshots)
entries.append(entry)
case 0:
let entry = AppsEntry(date: currentDate, relevance: TimelineEntryRelevance(score: 1.0), apps: snapshots)
entries.append(entry)
default:
// To reduce memory consumption, we only generate entries for the next week. This includes:
// * 1 for each day the "least expired" app is valid (up to 7)
// * 1 "0 days remaining"
// * 1 "Expired"
let numberOfEntries = min(numberOfDays, 7) + 2
let appEntries = (0 ..< numberOfEntries).map { (dayOffset) -> AppsEntry in
let entryDate = Calendar.current.date(byAdding: .day, value: dayOffset, to: currentDate) ?? currentDate.addingTimeInterval(Double(dayOffset) * 60 * 60 * 24)
let daysSinceRefresh = entryDate.numberOfCalendarDays(since: firstExpiringApp.refreshedDate)
let totalNumberOfDays = firstExpiringApp.expirationDate.numberOfCalendarDays(since: firstExpiringApp.refreshedDate)
var score = (entryDate <= firstExpiringApp.expirationDate) ? Float(daysSinceRefresh + 1) / Float(totalNumberOfDays + 1) : 1 // Expired apps have a score of 1.
if snapshots.allSatisfy({ $0.expirationDate > currentDate })
{
// Unless ALL apps are expired, in which case relevance is 0.
score = 0
}
let entry = AppsEntry(date: entryDate, relevance: TimelineEntryRelevance(score: score), apps: snapshots)
return entry
}
entries.append(contentsOf: appEntries)
}
return entries
}
}
extension AppsTimelineProvider: TimelineProvider
{
func getSnapshot(in context: Context, completion: @escaping (AppsEntry) -> Void)
{
Task<Void, Never> {
let bundleIDs = await self.fetchActiveAppBundleIDs()
let snapshot = await self.snapshot(for: bundleIDs)
completion(snapshot)
}
}
func getTimeline(in context: Context, completion: @escaping (Timeline<AppsEntry>) -> Void)
{
Task<Void, Never> {
let bundleIDs = await self.fetchActiveAppBundleIDs()
let timeline = await self.timeline(for: bundleIDs)
completion(timeline)
}
}
private func fetchActiveAppBundleIDs() async -> [String]
{
do
{
try await self.prepare()
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
let bundleIDs = try await context.performAsync {
let fetchRequest = InstalledApp.activeAppsFetchRequest() as! NSFetchRequest<NSDictionary>
fetchRequest.resultType = .dictionaryResultType
fetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier)]
let bundleIDs = try context.fetch(fetchRequest).compactMap { $0[#keyPath(InstalledApp.bundleIdentifier)] as? String }
return bundleIDs
}
return bundleIDs
}
catch
{
print("Failed to fetch active bundle IDs, falling back to AltStore bundle ID.", error)
return [StoreApp.altstoreAppID]
}
}
}

View File

@@ -15,13 +15,15 @@ struct Countdown: View
var endDate: Date?
var currentDate: Date = Date()
var strokeWidth: Double = 4.0
@Environment(\.font) private var font
private var numberOfDays: Int {
guard let date = self.endDate else { return 0 }
let numberOfDays = date.numberOfCalendarDays(since: self.currentDate)
return numberOfDays
return max(numberOfDays, 0) // Never show negative values.
}
private var fractionComplete: CGFloat {
@@ -35,7 +37,7 @@ struct Countdown: View
@ViewBuilder
private func overlay(progress: CGFloat) -> some View
{
let strokeStyle = StrokeStyle(lineWidth: 4.0, lineCap: .round, lineJoin: .round)
let strokeStyle = StrokeStyle(lineWidth: self.strokeWidth, lineCap: .round, lineJoin: .round)
if self.numberOfDays > 9 || self.numberOfDays < 0 {
Capsule(style: .continuous)

View File

@@ -39,3 +39,51 @@ extension AppSnapshot
self.icon = application?.icon?.resizing(toFill: CGSize(width: 180, height: 180))
}
}
extension AppSnapshot
{
static func makePreviewSnapshots() -> (altstore: AppSnapshot, delta: AppSnapshot, clip: AppSnapshot, longAltStore: AppSnapshot, longDelta: AppSnapshot, longClip: AppSnapshot)
{
let shortRefreshedDate = Calendar.current.date(byAdding: .day, value: -2, to: Date()) ?? Date()
let shortExpirationDate = Calendar.current.date(byAdding: .day, value: 7, to: shortRefreshedDate) ?? Date()
let longRefreshedDate = Calendar.current.date(byAdding: .day, value: -100, to: Date()) ?? Date()
let longExpirationDate = Calendar.current.date(byAdding: .day, value: 365, to: longRefreshedDate) ?? Date()
let altstore = AppSnapshot(name: "AltStore",
bundleIdentifier: "com.rileytestut.AltStore",
expirationDate: shortExpirationDate,
refreshedDate: shortRefreshedDate,
tintColor: .altPrimary,
icon: UIImage(named: "AltStore"))
let delta = AppSnapshot(name: "Delta",
bundleIdentifier: "com.rileytestut.Delta",
expirationDate: shortExpirationDate,
refreshedDate: shortRefreshedDate,
tintColor: .deltaPrimary,
icon: UIImage(named: "Delta"))
let clip = AppSnapshot(name: "Clip",
bundleIdentifier: "com.rileytestut.Clip",
expirationDate: shortExpirationDate,
refreshedDate: shortRefreshedDate,
tintColor: .clipPrimary,
icon: UIImage(named: "Clip"))
let longAltStore = altstore.with(refreshedDate: longRefreshedDate, expirationDate: longExpirationDate)
let longDelta = delta.with(refreshedDate: longRefreshedDate, expirationDate: longExpirationDate)
let longClip = clip.with(refreshedDate: longRefreshedDate, expirationDate: longExpirationDate)
return (altstore, delta, clip, longAltStore, longDelta, longClip)
}
private func with(refreshedDate: Date, expirationDate: Date) -> AppSnapshot
{
var app = self
app.refreshedDate = refreshedDate
app.expirationDate = expirationDate
return app
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ClipIcon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ClipIcon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -0,0 +1,158 @@
//
// HomeScreenWidget.swift
// AltWidgetExtension
//
// Created by Riley Testut on 8/16/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import SwiftUI
import WidgetKit
import CoreData
import AltStoreCore
import AltSign
private extension Color
{
static let altGradientLight = Color.init(.displayP3, red: 123.0/255.0, green: 200.0/255.0, blue: 176.0/255.0)
static let altGradientDark = Color.init(.displayP3, red: 0.0/255.0, green: 128.0/255.0, blue: 132.0/255.0)
static let altGradientExtraDark = Color.init(.displayP3, red: 2.0/255.0, green: 82.0/255.0, blue: 103.0/255.0)
}
@available(iOS 17, *)
struct ActiveAppsWidget: Widget
{
private let kind: String = "ActiveApps"
public var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: AppsTimelineProvider()) { entry in
ActiveAppsWidgetView(entry: entry)
}
.supportedFamilies([.systemMedium])
.configurationDisplayName("AltWidget")
.description("View remaining days until your active apps expire.")
}
}
@available(iOS 17, *)
private struct ActiveAppsWidgetView: View
{
var entry: AppsEntry
@Environment(\.colorScheme)
private var colorScheme
var body: some View {
Group {
if entry.apps.isEmpty
{
placeholder
}
else
{
content
}
}
.foregroundStyle(.white)
.containerBackground(for: .widget) {
if colorScheme == .dark
{
LinearGradient(colors: [.altGradientDark, .altGradientExtraDark], startPoint: .top, endPoint: .bottom)
}
else
{
LinearGradient(colors: [.altGradientLight, .altGradientDark], startPoint: .top, endPoint: .bottom)
}
}
}
private var content: some View {
GeometryReader { (geometry) in
let numberOfApps = max(entry.apps.count, 1) // Ensure we don't divide by 0
let preferredRowHeight = (geometry.size.height / Double(numberOfApps)) - 8
let rowHeight = min(preferredRowHeight, geometry.size.height / 2)
ZStack(alignment: .center) {
VStack(spacing: 12) {
ForEach(entry.apps, id: \.bundleIdentifier) { app in
let daysRemaining = app.expirationDate.numberOfCalendarDays(since: entry.date)
let cornerRadius = rowHeight / 5.0
HStack(spacing: 10) {
Image(uiImage: app.icon ?? UIImage(named: "AltStore")!)
.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(cornerRadius)
VStack(alignment: .leading, spacing: 1) {
Text(app.name)
.font(.system(size: 15, weight: .semibold, design: .rounded))
let text = if entry.date > app.expirationDate
{
Text("Expired")
}
else
{
Text("Expires in \(daysRemaining) ") + (daysRemaining == 1 ? Text("day") : Text("days"))
}
text
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
}
HStack {
Spacer()
Countdown(startDate: app.refreshedDate,
endDate: app.expirationDate,
currentDate: entry.date,
strokeWidth: 3.0) // Slightly thinner circle stroke width
.background {
Color.black.opacity(0.1)
.mask(Capsule())
.padding(.all, -5)
}
.font(.system(size: 16, weight: .semibold, design: .rounded))
.invalidatableContent()
}
.activatesRefreshAllAppsIntent()
}
.frame(height: rowHeight)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var placeholder: some View {
Text("App Not Found")
.font(.system(.body, design: .rounded))
.fontWeight(.semibold)
.foregroundColor(Color.white.opacity(0.4))
}
}
#Preview(as: .systemMedium) {
ActiveAppsWidget()
} timeline: {
let expiredDate = Date().addingTimeInterval(1 * 60 * 60 * 24 * 7)
let (altstore, delta, clip, longAltStore, longDelta, longClip) = AppSnapshot.makePreviewSnapshots()
AppsEntry(date: Date(), apps: [altstore, delta, clip])
AppsEntry(date: Date(), apps: [longAltStore, longDelta, longClip])
AppsEntry(date: expiredDate, apps: [altstore, delta, clip])
AppsEntry(date: Date(), apps: [altstore, delta])
AppsEntry(date: Date(), apps: [altstore])
AppsEntry(date: Date(), apps: [])
AppsEntry(date: Date(), apps: [], isPlaceholder: true)
}