[Widgets]: [WIP]: Implement Pagination in ActiveAppWidget based on user interaction

This commit is contained in:
Magesh K
2025-01-09 05:07:13 +05:30
parent bb8a1b57cd
commit 46871f63ed
7 changed files with 277 additions and 14 deletions

View File

@@ -55,6 +55,8 @@
A809F69F2D04D7B300F0F0F3 /* libem_proxy_static.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A809F6942D04D71200F0F0F3 /* libem_proxy_static.a */; }; A809F69F2D04D7B300F0F0F3 /* libem_proxy_static.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A809F6942D04D71200F0F0F3 /* libem_proxy_static.a */; };
A809F6A82D04DA1900F0F0F3 /* minimuxer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A32D04DA1900F0F0F3 /* minimuxer.swift */; }; A809F6A82D04DA1900F0F0F3 /* minimuxer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A32D04DA1900F0F0F3 /* minimuxer.swift */; };
A809F6A92D04DA1900F0F0F3 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A72D04DA1900F0F0F3 /* SwiftBridgeCore.swift */; }; A809F6A92D04DA1900F0F0F3 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A72D04DA1900F0F0F3 /* SwiftBridgeCore.swift */; };
A80D790D2D2F20AF00A40F40 /* PaginationIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */; };
A80D790F2D2F217000A40F40 /* PaginationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790E2D2F217000A40F40 /* PaginationViewModel.swift */; };
A82067842D03DC0600645C0D /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; A82067842D03DC0600645C0D /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; };
A82067C42D03E0DE00645C0D /* SemanticVersion in Frameworks */ = {isa = PBXBuildFile; productRef = A82067C32D03E0DE00645C0D /* SemanticVersion */; }; A82067C42D03E0DE00645C0D /* SemanticVersion in Frameworks */ = {isa = PBXBuildFile; productRef = A82067C32D03E0DE00645C0D /* SemanticVersion */; };
A859ED5C2D1EE827003DCC58 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; }; A859ED5C2D1EE827003DCC58 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; };
@@ -624,6 +626,8 @@
A809F6A52D04DA1900F0F0F3 /* minimuxer-helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "minimuxer-helpers.swift"; sourceTree = "<group>"; }; A809F6A52D04DA1900F0F0F3 /* minimuxer-helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "minimuxer-helpers.swift"; sourceTree = "<group>"; };
A809F6A62D04DA1900F0F0F3 /* SwiftBridgeCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SwiftBridgeCore.h; sourceTree = "<group>"; }; A809F6A62D04DA1900F0F0F3 /* SwiftBridgeCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SwiftBridgeCore.h; sourceTree = "<group>"; };
A809F6A72D04DA1900F0F0F3 /* SwiftBridgeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBridgeCore.swift; sourceTree = "<group>"; }; A809F6A72D04DA1900F0F0F3 /* SwiftBridgeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBridgeCore.swift; sourceTree = "<group>"; };
A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIntent.swift; sourceTree = "<group>"; };
A80D790E2D2F217000A40F40 /* PaginationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationViewModel.swift; sourceTree = "<group>"; };
A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:67RAULRX93:Marcin Krzyzanowski"; lastKnownFileType = wrapper.xcframework; name = OpenSSL.xcframework; path = SideStore/AltSign/Dependencies/OpenSSL/Frameworks/OpenSSL.xcframework; sourceTree = "<group>"; }; A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:67RAULRX93:Marcin Krzyzanowski"; lastKnownFileType = wrapper.xcframework; name = OpenSSL.xcframework; path = SideStore/AltSign/Dependencies/OpenSSL/Frameworks/OpenSSL.xcframework; sourceTree = "<group>"; };
A85ACB8E2D1F31C400AA3DE7 /* AltBackup.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltBackup.xcconfig; sourceTree = "<group>"; }; A85ACB8E2D1F31C400AA3DE7 /* AltBackup.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltBackup.xcconfig; sourceTree = "<group>"; };
A85ACB8F2D1F31C400AA3DE7 /* AltStore.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltStore.debug.xcconfig; sourceTree = "<group>"; }; A85ACB8F2D1F31C400AA3DE7 /* AltStore.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltStore.debug.xcconfig; sourceTree = "<group>"; };
@@ -1161,6 +1165,14 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A80D790B2D2F209700A40F40 /* Intents */ = {
isa = PBXGroup;
children = (
A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */,
);
path = Intents;
sourceTree = "<group>";
};
A85ACB942D1F31C400AA3DE7 /* xcconfigs */ = { A85ACB942D1F31C400AA3DE7 /* xcconfigs */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -1693,6 +1705,7 @@
BF98916C250AABF3002ACF50 /* AltWidget */ = { BF98916C250AABF3002ACF50 /* AltWidget */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A80D790B2D2F209700A40F40 /* Intents */,
A800F6FE2CE28DE300208744 /* Extensions */, A800F6FE2CE28DE300208744 /* Extensions */,
BF8B17F0250AC62400F8157F /* AltWidgetExtension.entitlements */, BF8B17F0250AC62400F8157F /* AltWidgetExtension.entitlements */,
D5FD4EC42A952EAD0097BEE8 /* AltWidgetBundle.swift */, D5FD4EC42A952EAD0097BEE8 /* AltWidgetBundle.swift */,
@@ -2067,6 +2080,7 @@
D50C29F22A8ECD71009AB488 /* Widgets */ = { D50C29F22A8ECD71009AB488 /* Widgets */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A80D790E2D2F217000A40F40 /* PaginationViewModel.swift */,
D577AB7E2A96878A007FE952 /* AppDetailWidget.swift */, D577AB7E2A96878A007FE952 /* AppDetailWidget.swift */,
D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */, D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */,
BF98917D250AAC4F002ACF50 /* LockScreenWidget.swift */, BF98917D250AAC4F002ACF50 /* LockScreenWidget.swift */,
@@ -2898,12 +2912,14 @@
D577AB7F2A96878A007FE952 /* AppDetailWidget.swift in Sources */, D577AB7F2A96878A007FE952 /* AppDetailWidget.swift in Sources */,
BF98917E250AAC4F002ACF50 /* Countdown.swift in Sources */, BF98917E250AAC4F002ACF50 /* Countdown.swift in Sources */,
D5151BE22A90363300C96F28 /* RefreshAllAppsWidgetIntent.swift in Sources */, D5151BE22A90363300C96F28 /* RefreshAllAppsWidgetIntent.swift in Sources */,
A80D790D2D2F20AF00A40F40 /* PaginationIntent.swift in Sources */,
D5FD4EC92A9530C00097BEE8 /* AppSnapshot.swift in Sources */, D5FD4EC92A9530C00097BEE8 /* AppSnapshot.swift in Sources */,
D5151BE72A90395400C96F28 /* View+AltWidget.swift in Sources */, D5151BE72A90395400C96F28 /* View+AltWidget.swift in Sources */,
BF98917F250AAC4F002ACF50 /* LockScreenWidget.swift in Sources */, BF98917F250AAC4F002ACF50 /* LockScreenWidget.swift in Sources */,
A800F7042CE28E3800208744 /* View+AltWidget.swift in Sources */, A800F7042CE28E3800208744 /* View+AltWidget.swift in Sources */,
BF98917F250AAC4F002ACF50 /* LockScreenWidget.swift in Sources */, BF98917F250AAC4F002ACF50 /* LockScreenWidget.swift in Sources */,
D5FD4EC52A952EAD0097BEE8 /* AltWidgetBundle.swift in Sources */, D5FD4EC52A952EAD0097BEE8 /* AltWidgetBundle.swift in Sources */,
A80D790F2D2F217000A40F40 /* PaginationViewModel.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@@ -24,6 +24,29 @@ struct AppsTimelineProvider
{ {
typealias Entry = AppsEntry typealias Entry = AppsEntry
private var viewModel: PaginationViewModel?
private let widgetID: String?
init(_ viewModel: PaginationViewModel? = nil){
self.viewModel = viewModel
self.widgetID = viewModel?.widgetID
}
private func reloadEntries(apps: [AppSnapshot]){
guard let viewModel = self.viewModel else { return }
let entries = Set(viewModel.backup_entries.map{ (app: AppSnapshot) in app.bundleIdentifier })
let app_entries = Set(apps.map{ (app: AppSnapshot) in app.bundleIdentifier })
// this updates the in-memory entries
if entries.isEmpty || entries != app_entries{
self.viewModel?.setEntries(apps)
// initialize the view
self.viewModel?.handlePagination(.up)
}
}
func placeholder(in context: TimelineProviderContext) -> AppsEntry func placeholder(in context: TimelineProviderContext) -> AppsEntry
{ {
return AppsEntry(date: Date(), apps: [], isPlaceholder: true) return AppsEntry(date: Date(), apps: [], isPlaceholder: true)
@@ -37,6 +60,9 @@ struct AppsTimelineProvider
let apps = try await self.fetchApps(withBundleIDs: appBundleIDs) let apps = try await self.fetchApps(withBundleIDs: appBundleIDs)
// send this for pagination
reloadEntries(apps: apps)
let entry = AppsEntry(date: Date(), apps: apps) let entry = AppsEntry(date: Date(), apps: apps)
return entry return entry
} }
@@ -57,6 +83,9 @@ struct AppsTimelineProvider
let apps = try await self.fetchApps(withBundleIDs: appBundleIDs) let apps = try await self.fetchApps(withBundleIDs: appBundleIDs)
// send this for pagination
reloadEntries(apps: apps)
let entries = self.makeEntries(for: apps) let entries = self.makeEntries(for: apps)
let timeline = Timeline(entries: entries, policy: .atEnd) let timeline = Timeline(entries: entries, policy: .atEnd)
return timeline return timeline
@@ -203,6 +232,7 @@ extension AppsTimelineProvider: TimelineProvider
extension AppsTimelineProvider: IntentTimelineProvider extension AppsTimelineProvider: IntentTimelineProvider
{ {
typealias Intent = ViewAppIntent typealias Intent = ViewAppIntent
func getSnapshot(for intent: Intent, in context: Context, completion: @escaping (AppsEntry) -> Void) func getSnapshot(for intent: Intent, in context: Context, completion: @escaping (AppsEntry) -> Void)

View File

@@ -53,4 +53,29 @@ extension View
self self
} }
} }
@ViewBuilder
func pageUpButton(widgetID: String) -> some View {
if #available(iOSApplicationExtension 17, *) {
Button(intent: PaginationIntent(.up, widgetID)){
self
}
.buttonStyle(.plain)
} else {
self
}
}
@ViewBuilder
func pageDownButton(widgetID: String) -> some View {
if #available(iOSApplicationExtension 17, *) {
Button(intent: PaginationIntent(.down, widgetID)){
self
}
.buttonStyle(.plain)
} else {
self
}
}
} }

View File

@@ -0,0 +1,46 @@
//
// PaginationIntent.swift
// AltStore
//
// Created by Magesh K on 08/01/25.
// Copyright © 2025 SideStore. All rights reserved.
//
import AppIntents
import WidgetKit
public enum Direction: String{
case up = "up"
case down = "down"
}
@available(iOS 17, *)
struct PaginationIntent: AppIntent, @unchecked Sendable {
static var title: LocalizedStringResource { "Scroll up or down in Active Apps Widget" }
static var isDiscoverable: Bool { false }
@Parameter(title: "Direction")
var direction: String
@Parameter(title: "Widget Identifier")
var widgetID: String
init(){}
init(_ direction: Direction, _ widgetID: String){
self.direction = direction.rawValue
self.widgetID = widgetID
}
func perform() async throws -> some IntentResult {
let direction = Direction(rawValue: direction)!
guard let viewModel = PaginationViewModel.instance(widgetID) else{
print("viewModel for widgetID: \(widgetID) not found, ignoring request")
return .result()
}
viewModel.handlePagination(direction)
WidgetCenter.shared.reloadTimelines(ofKind: viewModel.widgetID)
return .result()
}
}

View File

@@ -24,17 +24,25 @@ private extension Color
//@available(iOS 17, *) //@available(iOS 17, *)
struct ActiveAppsWidget: Widget struct ActiveAppsWidget: Widget
{ {
private let kind: String = "ActiveApps" private var viewModel = PaginationViewModel.getNewInstance(
"ActiveApps" + UUID().uuidString
)
public var body: some WidgetConfiguration { public var body: some WidgetConfiguration {
if #available(iOS 17, *) if #available(iOS 17, *)
{ {
return StaticConfiguration(kind: kind, provider: AppsTimelineProvider()) { entry in let staticConfig = StaticConfiguration(
ActiveAppsWidgetView(entry: entry) kind: viewModel.widgetID,
provider: AppsTimelineProvider(viewModel)
) { entry in
ActiveAppsWidgetView(entry: entry, viewModel: viewModel)
} }
.supportedFamilies([.systemMedium]) .supportedFamilies([.systemMedium])
.configurationDisplayName("Active Apps") .configurationDisplayName("Active Apps")
.description("View remaining days until your active apps expire. Tap the countdown timers to refresh them in the background.") .description("View remaining days until your active apps expire. Tap the countdown timers to refresh them in the background.")
// this widgetConfiguration is requested/drawn once per widget per process lifecycle
return staticConfig
} }
else else
{ {
@@ -50,9 +58,16 @@ private struct ActiveAppsWidgetView: View
{ {
var entry: AppsEntry var entry: AppsEntry
@ObservedObject private var viewModel: PaginationViewModel
@Environment(\.colorScheme) @Environment(\.colorScheme)
private var colorScheme private var colorScheme
init(entry: AppsEntry, viewModel: PaginationViewModel){
self.entry = entry
self.viewModel = viewModel
}
var body: some View { var body: some View {
Group { Group {
if entry.apps.isEmpty if entry.apps.isEmpty
@@ -80,15 +95,19 @@ private struct ActiveAppsWidgetView: View
private var content: some View { private var content: some View {
GeometryReader { (geometry) in GeometryReader { (geometry) in
let numberOfApps = max(entry.apps.count, 1) // Ensure we don't divide by 0 let MAX_ROWS_PER_PAGE = PaginationViewModel.MAX_ROWS_PER_PAGE
let preferredRowHeight = (geometry.size.height / Double(numberOfApps)) - 8
let preferredRowHeight = (geometry.size.height / Double(MAX_ROWS_PER_PAGE)) - 8
let rowHeight = min(preferredRowHeight, geometry.size.height / 2) let rowHeight = min(preferredRowHeight, geometry.size.height / 2)
ZStack(alignment: .center) { HStack(alignment: .center) {
VStack(spacing: 12) {
ForEach(entry.apps, id: \.bundleIdentifier) { app in
let icon = app.icon ?? UIImage(named: "SideStore")! // VStack(spacing: 12) {
LazyVStack(spacing: 12) {
ForEach($viewModel.sliding_window, id: \.bundleIdentifier) { app in
let app = app.wrappedValue // remove the binding
let icon: UIImage = app.icon ?? UIImage(named: "SideStore")!
// 1024x1024 images are not supported by previews but supported by device // 1024x1024 images are not supported by previews but supported by device
// so we scale the image to 97% so as to reduce its actual size but not too much // so we scale the image to 97% so as to reduce its actual size but not too much
@@ -142,12 +161,33 @@ private struct ActiveAppsWidgetView: View
} }
.font(.system(size: 16, weight: .semibold, design: .rounded)) .font(.system(size: 16, weight: .semibold, design: .rounded))
.invalidatableContent() .invalidatableContent()
.padding(.horizontal, 8)
.activatesRefreshAllAppsIntent() .activatesRefreshAllAppsIntent()
} }
.frame(height: rowHeight) .frame(height: rowHeight)
} }
} }
Spacer(minLength: 16)
let buttonWidth: CGFloat = 16
VStack {
Image(systemName: "arrow.up")
.resizable()
.frame(width: buttonWidth, height: buttonWidth)
.mask(Capsule())
.opacity(0.3)
.pageUpButton(widgetID: viewModel.widgetID)
Spacer()
Image(systemName: "arrow.down")
.resizable()
.frame(width: buttonWidth, height: buttonWidth)
.opacity(0.3)
.mask(Capsule())
.pageDownButton(widgetID: viewModel.widgetID)
}
.padding(.vertical)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }

View File

@@ -82,6 +82,7 @@ private struct ComplicationView: View
let progress = Double(daysRemaining) / Double(totalDays) let progress = Double(daysRemaining) / Double(totalDays)
// TODO: Gauge initialized with an out-of-bounds progress amount. The amount will be clamped to the nearest bound.
Gauge(value: progress) { Gauge(value: progress) {
if daysRemaining < 0 if daysRemaining < 0
{ {

View File

@@ -0,0 +1,105 @@
//
// PaginationViewModel.swift
// AltStore
//
// Created by Magesh K on 09/01/25.
// Copyright © 2025 SideStore. All rights reserved.
//
import Foundation
import Combine
class PaginationViewModel: ObservableObject {
private static var instances: [String:PaginationViewModel] = [:]
public static let MAX_ROWS_PER_PAGE = 3
@Published public var sliding_window: [AppSnapshot] = []
@Published public var refreshed: Bool = false
private init(){}
private var _widgetID: String?
public lazy var widgetID: String = {
return _widgetID!
}()
public static func getNewInstance(_ widgetID: String) -> PaginationViewModel {
let instance = PaginationViewModel()
PaginationViewModel.instances[widgetID] = instance
instance._widgetID = widgetID
return instance
}
public static func instance(_ widgetID: String) -> PaginationViewModel? {
return PaginationViewModel.instances[widgetID]
}
public private(set) var backup_entries: [AppSnapshot] = []
private var r_queue: [AppSnapshot] = []
private var l_queue: [AppSnapshot] = []
private var lastIndex: Int { r_queue.count - 1 }
public func setEntries(_ entries: [AppSnapshot]) {
r_queue = entries
backup_entries = entries
}
public func handlePagination(_ direction: Direction) {
var sliding_window = Array(sliding_window)
var l_queue = Array(l_queue)
var r_queue = Array(r_queue)
// If entries is empty, do nothing
guard !backup_entries.isEmpty else {
sliding_window.removeAll()
return
}
switch direction {
case .up:
// move window contents to left-q since we are moving right side
if !sliding_window.isEmpty {
// take the window as-is and put it to right of l_queue
l_queue.append(contentsOf: sliding_window)
}
// clear the window
sliding_window.removeAll()
let size = min(r_queue.count, Self.MAX_ROWS_PER_PAGE)
for _ in 0..<size {
if !r_queue.isEmpty {
sliding_window.append(r_queue.removeFirst())
}
}
case .down:
// move window contents to right-q since we are moving left side
if !sliding_window.isEmpty {
// take the window as-is and put it to left of r_queue
r_queue.insert(contentsOf: sliding_window, at: 0)
}
// clear the window
sliding_window.removeAll()
let size = min(l_queue.count, Self.MAX_ROWS_PER_PAGE)
for _ in 0..<size {
sliding_window.insert(l_queue.removeLast(), at: 0)
}
}
if !sliding_window.isEmpty {
// commit
self.sliding_window = sliding_window
self.l_queue = l_queue
self.r_queue = r_queue
self.refreshed = true
}
}
}