|
| 1 | +# Live Activities & Dynamic Island Feature |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Add iOS Live Activities and Dynamic Island support for model download progress, allowing users to see download status on the lock screen and Dynamic Island without opening the app. |
| 6 | + |
| 7 | +## Visual Mockups |
| 8 | + |
| 9 | +``` |
| 10 | +┌──────────────────────────────────────────────┐ |
| 11 | +│ Dynamic Island (Compact) │ |
| 12 | +│ ┌──────┐ ┌────────┐ │ |
| 13 | +│ │ 💎 │ ▓▓▓▓▓▓▓░░░ 68% │ │ │ |
| 14 | +│ └──────┘ └────────┘ │ |
| 15 | +└──────────────────────────────────────────────┘ |
| 16 | +
|
| 17 | +┌──────────────────────────────────────────────┐ |
| 18 | +│ Dynamic Island (Expanded - on tap/hold) │ |
| 19 | +│ ┌────────────────────────────────────────┐ │ |
| 20 | +│ │ 💎 Downloading Gemma 3 1B │ │ |
| 21 | +│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░ 68% │ │ |
| 22 | +│ │ 458 MB / 689 MB • 2.3 MB/s │ │ |
| 23 | +│ └────────────────────────────────────────┘ │ |
| 24 | +└──────────────────────────────────────────────┘ |
| 25 | +
|
| 26 | +┌──────────────────────────────────────────────┐ |
| 27 | +│ Lock Screen Live Activity │ |
| 28 | +│ ┌────────────────────────────────────────┐ │ |
| 29 | +│ │ 💎 Gemma 3 1B │ │ |
| 30 | +│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░ 68% │ │ |
| 31 | +│ │ 458 MB / 689 MB │ │ |
| 32 | +│ └────────────────────────────────────────┘ │ |
| 33 | +└──────────────────────────────────────────────┘ |
| 34 | +``` |
| 35 | + |
| 36 | +## Architecture |
| 37 | + |
| 38 | +``` |
| 39 | +┌─────────────────────────────────────────────────────────────────┐ |
| 40 | +│ LIVE ACTIVITIES DATA FLOW │ |
| 41 | +├─────────────────────────────────────────────────────────────────┤ |
| 42 | +│ │ |
| 43 | +│ ┌─────────────────────┐ ┌─────────────────────────────┐ │ |
| 44 | +│ │ Flutter App │ │ iOS Widget Extension │ │ |
| 45 | +│ │ (Dart) │ ──→ │ (Swift/SwiftUI) │ │ |
| 46 | +│ │ │ │ │ │ |
| 47 | +│ │ • live_activities │ │ • LiveActivitiesAppAttributes│ │ |
| 48 | +│ │ plugin │ │ • Lock Screen UI │ │ |
| 49 | +│ │ • createActivity() │ │ • Dynamic Island UI │ │ |
| 50 | +│ │ • updateActivity() │ │ - Compact view │ │ |
| 51 | +│ │ │ │ - Expanded view │ │ |
| 52 | +│ └─────────────────────┘ │ - Minimal view │ │ |
| 53 | +│ │ └─────────────────────────────┘ │ |
| 54 | +│ │ ↑ │ |
| 55 | +│ └──── UserDefaults (App Group) ┘ │ |
| 56 | +│ │ |
| 57 | +│ Data passed via UserDefaults: │ |
| 58 | +│ • modelName: String (e.g., "Gemma 3 1B") │ |
| 59 | +│ • progress: Double (0.0 - 1.0) │ |
| 60 | +│ • downloadedMB: Int │ |
| 61 | +│ • totalMB: Int │ |
| 62 | +│ • downloadSpeed: String (optional) │ |
| 63 | +│ │ |
| 64 | +└─────────────────────────────────────────────────────────────────┘ |
| 65 | +``` |
| 66 | + |
| 67 | +## Requirements |
| 68 | + |
| 69 | +- iOS 16.1+ (Live Activities) |
| 70 | +- iPhone 14 Pro+ (Dynamic Island) |
| 71 | +- Plugin: `live_activities: ^2.4.3` |
| 72 | + |
| 73 | +## Implementation Tasks |
| 74 | + |
| 75 | +### Phase 1: iOS Setup (Xcode) |
| 76 | + |
| 77 | +- [ ] Create Widget Extension target in Xcode |
| 78 | + - File → New → Target → Widget Extension |
| 79 | + - Name: `WingManWidgetExtension` |
| 80 | + - Embed in: Runner |
| 81 | +- [ ] Add `NSSupportsLiveActivities: true` to Info.plist (Runner + Extension) |
| 82 | +- [ ] Enable App Groups capability for both Runner and Extension |
| 83 | + - Group ID: `group.com.poly186.wingman` |
| 84 | +- [ ] Enable Push Notifications capability for Runner |
| 85 | + |
| 86 | +### Phase 2: Native SwiftUI Views |
| 87 | + |
| 88 | +- [ ] Create `LiveActivitiesAppAttributes` struct (MUST use this exact name) |
| 89 | +- [ ] Design Lock Screen view |
| 90 | +- [ ] Design Dynamic Island compact view (leading/trailing) |
| 91 | +- [ ] Design Dynamic Island expanded view |
| 92 | +- [ ] Design Dynamic Island minimal view (for multiple activities) |
| 93 | + |
| 94 | +### Phase 3: Flutter Integration |
| 95 | + |
| 96 | +- [ ] Add `live_activities` to pubspec.yaml |
| 97 | +- [ ] Initialize plugin with App Group ID |
| 98 | +- [ ] Create `LiveActivityService` in Dart |
| 99 | +- [ ] Integrate with `LlmService.installModel()` progress callback |
| 100 | +- [ ] Start activity when download begins |
| 101 | +- [ ] Update activity on progress changes (throttle to every 2-5%) |
| 102 | +- [ ] End activity when download completes or fails |
| 103 | + |
| 104 | +### Phase 4: Polish |
| 105 | + |
| 106 | +- [ ] Add WingMan branding/colors to native views |
| 107 | +- [ ] Handle edge cases (app killed, multiple downloads) |
| 108 | +- [ ] Test on real devices |
| 109 | +- [ ] Add Android RemoteViews support (optional) |
| 110 | + |
| 111 | +## Code Snippets |
| 112 | + |
| 113 | +### Flutter Side (Dart) |
| 114 | + |
| 115 | +```dart |
| 116 | +// lib/services/live_activity_service.dart |
| 117 | +import 'package:live_activities/live_activities.dart'; |
| 118 | +
|
| 119 | +class LiveActivityService { |
| 120 | + final _plugin = LiveActivities(); |
| 121 | + String? _currentActivityId; |
| 122 | + |
| 123 | + Future<void> init() async { |
| 124 | + await _plugin.init(appGroupId: 'group.com.poly186.wingman'); |
| 125 | + } |
| 126 | + |
| 127 | + Future<void> startDownloadActivity({ |
| 128 | + required String modelName, |
| 129 | + required int totalMB, |
| 130 | + }) async { |
| 131 | + _currentActivityId = await _plugin.createActivity({ |
| 132 | + 'modelName': modelName, |
| 133 | + 'progress': 0.0, |
| 134 | + 'downloadedMB': 0, |
| 135 | + 'totalMB': totalMB, |
| 136 | + }); |
| 137 | + } |
| 138 | + |
| 139 | + Future<void> updateProgress(double progress, int downloadedMB) async { |
| 140 | + if (_currentActivityId == null) return; |
| 141 | + await _plugin.updateActivity(_currentActivityId!, { |
| 142 | + 'progress': progress, |
| 143 | + 'downloadedMB': downloadedMB, |
| 144 | + }); |
| 145 | + } |
| 146 | + |
| 147 | + Future<void> endActivity() async { |
| 148 | + if (_currentActivityId == null) return; |
| 149 | + await _plugin.endActivity(_currentActivityId!); |
| 150 | + _currentActivityId = null; |
| 151 | + } |
| 152 | +} |
| 153 | +``` |
| 154 | + |
| 155 | +### iOS Side (Swift) |
| 156 | + |
| 157 | +```swift |
| 158 | +// WingManWidgetExtension/WingManLiveActivity.swift |
| 159 | +import ActivityKit |
| 160 | +import SwiftUI |
| 161 | +import WidgetKit |
| 162 | + |
| 163 | +struct LiveActivitiesAppAttributes: ActivityAttributes, Identifiable { |
| 164 | + public typealias LiveDeliveryData = ContentState |
| 165 | + public struct ContentState: Codable, Hashable { } |
| 166 | + var id = UUID() |
| 167 | +} |
| 168 | + |
| 169 | +extension LiveActivitiesAppAttributes { |
| 170 | + func prefixedKey(_ key: String) -> String { |
| 171 | + return "\(id)_\(key)" |
| 172 | + } |
| 173 | +} |
| 174 | + |
| 175 | +struct WingManLiveActivity: Widget { |
| 176 | + let sharedDefault = UserDefaults(suiteName: "group.com.poly186.wingman")! |
| 177 | + |
| 178 | + var body: some WidgetConfiguration { |
| 179 | + ActivityConfiguration(for: LiveActivitiesAppAttributes.self) { context in |
| 180 | + // Lock Screen UI |
| 181 | + let modelName = sharedDefault.string(forKey: context.attributes.prefixedKey("modelName")) ?? "Model" |
| 182 | + let progress = sharedDefault.double(forKey: context.attributes.prefixedKey("progress")) |
| 183 | + let downloadedMB = sharedDefault.integer(forKey: context.attributes.prefixedKey("downloadedMB")) |
| 184 | + let totalMB = sharedDefault.integer(forKey: context.attributes.prefixedKey("totalMB")) |
| 185 | + |
| 186 | + VStack(alignment: .leading, spacing: 8) { |
| 187 | + HStack { |
| 188 | + Text("💎") |
| 189 | + Text(modelName) |
| 190 | + .font(.headline) |
| 191 | + .foregroundColor(.white) |
| 192 | + } |
| 193 | + |
| 194 | + ProgressView(value: progress) |
| 195 | + .tint(.blue) |
| 196 | + |
| 197 | + Text("\(downloadedMB) MB / \(totalMB) MB") |
| 198 | + .font(.caption) |
| 199 | + .foregroundColor(.gray) |
| 200 | + } |
| 201 | + .padding() |
| 202 | + .activityBackgroundTint(.black) |
| 203 | + |
| 204 | + } dynamicIsland: { context in |
| 205 | + let progress = sharedDefault.double(forKey: context.attributes.prefixedKey("progress")) |
| 206 | + let percent = Int(progress * 100) |
| 207 | + |
| 208 | + DynamicIsland { |
| 209 | + // Expanded view |
| 210 | + DynamicIslandExpandedRegion(.leading) { |
| 211 | + Text("💎") |
| 212 | + } |
| 213 | + DynamicIslandExpandedRegion(.trailing) { |
| 214 | + Text("\(percent)%") |
| 215 | + .font(.headline) |
| 216 | + } |
| 217 | + DynamicIslandExpandedRegion(.bottom) { |
| 218 | + ProgressView(value: progress) |
| 219 | + .tint(.blue) |
| 220 | + } |
| 221 | + } compactLeading: { |
| 222 | + Text("💎") |
| 223 | + } compactTrailing: { |
| 224 | + Text("\(percent)%") |
| 225 | + .font(.caption) |
| 226 | + } minimal: { |
| 227 | + // Circular progress for minimal view |
| 228 | + ProgressView(value: progress) |
| 229 | + .progressViewStyle(.circular) |
| 230 | + .scaleEffect(0.5) |
| 231 | + } |
| 232 | + } |
| 233 | + } |
| 234 | +} |
| 235 | +``` |
| 236 | + |
| 237 | +## Estimated Effort |
| 238 | + |
| 239 | +| Task | Time | |
| 240 | +|------|------| |
| 241 | +| Xcode setup | 30 min | |
| 242 | +| SwiftUI views | 2-3 hours | |
| 243 | +| Flutter integration | 1 hour | |
| 244 | +| Testing & polish | 1-2 hours | |
| 245 | +| **Total** | **~5-6 hours** | |
| 246 | + |
| 247 | +## References |
| 248 | + |
| 249 | +- [live_activities Flutter plugin](https://pub.dev/packages/live_activities) |
| 250 | +- [Apple ActivityKit docs](https://developer.apple.com/documentation/activitykit) |
| 251 | +- [Canopas tutorial](https://canopas.com/integrating-live-activity-and-dynamic-island-in-i-os-a-complete-guide) |
| 252 | + |
| 253 | +## Status |
| 254 | + |
| 255 | +**Priority**: Nice-to-have (v2.0) |
| 256 | +**Status**: Planning |
| 257 | +**Blocked by**: None |
0 commit comments