From 1b1650f91101ed1577e47fef2d259a38651f986d Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Wed, 4 Feb 2026 11:11:00 -0300 Subject: [PATCH] Show batch progress in UI, add a Completed state for this --- lib/screens/updates_screen.dart | 60 +++++--- .../android_package_manager.dart | 38 ++++- .../package_manager/install_operation.dart | 19 +++ .../package_manager/package_manager.dart | 136 ++++++++++++++++++ lib/widgets/batch_progress_banner.dart | 78 ++++++++++ lib/widgets/install_button.dart | 9 ++ spec/features/FEAT-001-package-manager.md | 19 +++ work/WORK-002-batch-progress.md | 88 ++++++++++++ 8 files changed, 426 insertions(+), 21 deletions(-) create mode 100644 lib/widgets/batch_progress_banner.dart create mode 100644 work/WORK-002-batch-progress.md diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 584c3cd..506d08e 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -7,6 +7,7 @@ import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/batch_progress_banner.dart'; import 'package:zapstore/widgets/common/badges.dart'; import 'package:zapstore/widgets/app_card.dart'; @@ -105,6 +106,7 @@ class _ConnectionStatusIndicator extends ConsumerWidget { /// Item types for the updates list enum _UpdatesItemType { connectionStatus, + batchProgress, installingHeader, installingApp, automaticHeader, @@ -292,6 +294,9 @@ class _UpdatesListBody extends StatelessWidget { // Always add connection status at the top items.add(const _UpdatesItem(_UpdatesItemType.connectionStatus)); + // Add batch progress banner (shows only when operations are active) + items.add(const _UpdatesItem(_UpdatesItemType.batchProgress)); + if (installingApps.isNotEmpty) { items.add(const _UpdatesItem(_UpdatesItemType.installingHeader)); for (final app in installingApps) { @@ -337,6 +342,8 @@ class _UpdatesListBody extends StatelessWidget { switch (item.type) { case _UpdatesItemType.connectionStatus: return const _ConnectionStatusIndicator(); + case _UpdatesItemType.batchProgress: + return const BatchProgressBanner(); case _UpdatesItemType.installingHeader: return _InstallingHeader(count: installingApps.length); case _UpdatesItemType.installingApp: @@ -441,6 +448,9 @@ class _UpdatesSectionHeader extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Check if any operations are active (disable button during batch operations) + final hasActiveOps = ref.watch(activeOperationsCountProvider) > 0; + return Padding( padding: const EdgeInsets.all(16.0), child: Wrap( @@ -462,23 +472,32 @@ class _UpdatesSectionHeader extends ConsumerWidget { ], ), AsyncButtonBuilder( - onPressed: () async { - final pm = ref.read(packageManagerProvider.notifier); - final items = apps - .where((app) => app.latestFileMetadata != null) - .map( - (app) => ( - appId: app.identifier, - target: app.latestFileMetadata!, - displayName: app.name, - ), - ) - .toList(); - await pm.queueDownloads(items); - }, + onPressed: hasActiveOps + ? null // Disable when operations in progress + : () async { + final pm = ref.read(packageManagerProvider.notifier); + final items = apps + .where((app) => app.latestFileMetadata != null) + .map( + (app) => ( + appId: app.identifier, + target: app.latestFileMetadata!, + displayName: app.name, + ), + ) + .toList(); + await pm.queueDownloads(items); + }, builder: (context, child, callback, buttonState) { - const pillBg = AppColors.darkPillBackground; const pillText = Colors.white; + final isLoading = buttonState.maybeWhen( + loading: () => true, + orElse: () => false, + ); + final isDisabled = hasActiveOps || isLoading; + final pillBg = isDisabled + ? AppColors.darkPillBackground.withValues(alpha: 0.5) + : AppColors.darkPillBackground; return TextButton.icon( onPressed: buttonState.maybeWhen( @@ -494,15 +513,18 @@ class _UpdatesSectionHeader extends ConsumerWidget { color: pillText, ), ), - orElse: () => - const Icon(Icons.download, size: 14, color: pillText), + orElse: () => Icon( + Icons.download, + size: 14, + color: isDisabled ? pillText.withValues(alpha: 0.5) : pillText, + ), ), - label: const Text( + label: Text( 'Update All', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: pillText, + color: isDisabled ? pillText.withValues(alpha: 0.5) : pillText, ), ), style: TextButton.styleFrom( diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index ff314cc..2316861 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -220,12 +220,14 @@ final class AndroidPackageManager extends PackageManager { // database may not have committed yet, causing a race condition where // we get stale data and show "Update" instead of "Open". _updateInstalledPackage(appId, target); - clearOperation(appId); + // Transition to Completed state (stays in map for batch progress tracking) + setOperation(appId, Completed(target: target)); // Sync in background to get accurate info (signature hash, etc.) // but our state machine doesn't depend on it. unawaited(syncInstalledPackages()); - // Advance to next queued install + // Advance to next queued install and check if batch is complete _onInstallComplete(appId); + _checkBatchComplete(); break; case InstallStatus.failed: @@ -273,6 +275,38 @@ final class AndroidPackageManager extends PackageManager { scheduleProcessQueue(); } + /// Check if all operations are complete (terminal states). + /// If so, schedule clearing of completed operations after a delay. + void _checkBatchComplete() { + final ops = state.operations.values; + if (ops.isEmpty) return; + + // Check if any operation is still in progress + final hasInProgress = ops.any((op) => op.isInProgress); + if (hasInProgress) return; + + // All operations are terminal (Completed or Failed) + // Wait a moment to show "X of X updated", then clear completed ones + Future.delayed(const Duration(seconds: 3), _clearCompletedOperations); + } + + /// Clear all Completed operations from the map. + /// Failed operations stay so user can see/dismiss them. + void _clearCompletedOperations() { + final completedIds = state.operations.entries + .where((e) => e.value is Completed) + .map((e) => e.key) + .toList(); + + if (completedIds.isEmpty) return; + + final newOps = Map.from(state.operations); + for (final id in completedIds) { + newOps.remove(id); + } + state = state.copyWith(operations: newOps); + } + /// Convert native error code to FailureType. /// Uses structured error code when available, falls back to message parsing. FailureType _errorCodeToFailureType(String? errorCode, String? message) { diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart index 0d13b68..6ad2768 100644 --- a/lib/services/package_manager/install_operation.dart +++ b/lib/services/package_manager/install_operation.dart @@ -146,6 +146,19 @@ class SystemProcessing extends InstallOperation { const SystemProcessing({required super.target, required this.filePath}); } +// ═══════════════════════════════════════════════════════════════════════════════ +// TERMINAL STATES +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Operation completed successfully. +/// Stays in operations map until batch completes (for progress tracking). +class Completed extends InstallOperation { + final DateTime completedAt; + + Completed({required super.target, DateTime? completedAt}) + : completedAt = completedAt ?? DateTime.now(); +} + // ═══════════════════════════════════════════════════════════════════════════════ // FAILURE STATE (Dismiss available) // ═══════════════════════════════════════════════════════════════════════════════ @@ -216,6 +229,12 @@ extension InstallOperationX on InstallOperation { /// Whether this operation is in the verification phase bool get isVerifying => this is Verifying; + /// Whether this operation is in a terminal state (completed or failed) + bool get isTerminal => this is Completed || this is OperationFailed; + + /// Whether this operation is still in progress (not terminal) + bool get isInProgress => !isTerminal; + /// Get file path if available String? get filePath => switch (this) { Verifying(:final filePath) => filePath, diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 474144e..bd65f52 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -1226,3 +1226,139 @@ final systemOnlyPackagesProvider = ), ); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// BATCH PROGRESS (Fully Derived State) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Current phase of batch operations +enum BatchPhase { downloading, verifying, installing, completed, idle } + +/// Batch progress summary - ALL state derived from operations map. +/// +/// Key insight: total = operations.length (includes Completed state). +/// When an operation succeeds, it transitions to Completed instead of being removed. +/// This allows us to derive completed count without separate tracking. +class BatchProgress { + const BatchProgress({ + required this.total, + required this.completed, + required this.downloading, + required this.verifying, + required this.installing, + required this.queued, + required this.failed, + required this.phase, + }); + + /// Total operations (everything in the map, including completed) + final int total; + + /// Operations that completed successfully + final int completed; + + /// Operations currently downloading + final int downloading; + + /// Operations currently verifying + final int verifying; + + /// Operations currently installing + final int installing; + + /// Operations waiting in queue + final int queued; + + /// Operations that failed + final int failed; + + /// Current dominant phase + final BatchPhase phase; + + /// Whether any operations are in progress (not terminal) + bool get hasInProgress => + downloading > 0 || verifying > 0 || installing > 0 || queued > 0; + + /// Whether all operations are complete (all terminal) + bool get isAllComplete => !hasInProgress && total > 0; + + /// Status text for display + String get statusText { + if (total == 0) return ''; + + final completedText = '$completed of $total updated'; + + return switch (phase) { + BatchPhase.installing => '$completedText • Installing...', + BatchPhase.verifying => '$completedText • Verifying...', + BatchPhase.downloading => '$completedText • Downloading...', + BatchPhase.completed => '$completedText ✓', + BatchPhase.idle => completedText, + }; + } +} + +/// Provider for batch progress - ALL state derived from operations map. +/// +/// No parameters needed - derives everything from PackageManagerState.operations. +final batchProgressProvider = Provider((ref) { + final ops = ref.watch(packageManagerProvider.select((s) => s.operations)); + + // No operations = no banner + if (ops.isEmpty) return null; + + // Count operations by type + int completed = 0, + downloading = 0, + verifying = 0, + installing = 0, + queued = 0, + failed = 0; + + for (final op in ops.values) { + switch (op) { + case Completed(): + completed++; + case DownloadQueued() || ReadyToInstall(): + queued++; + case Downloading() || DownloadPaused(): + downloading++; + case Verifying(): + verifying++; + case Installing() || SystemProcessing(): + installing++; + case OperationFailed(): + failed++; + case InstallCancelled(): + queued++; // User can retry + case AwaitingPermission(): + queued++; // Waiting for permission + case Uninstalling(): + installing++; // Count as install phase + } + } + + final total = ops.length; + + // Determine current phase (priority: installing > verifying > downloading > completed) + final phase = installing > 0 + ? BatchPhase.installing + : verifying > 0 + ? BatchPhase.verifying + : downloading > 0 + ? BatchPhase.downloading + : (completed > 0 && queued == 0) + ? BatchPhase.completed + : BatchPhase.idle; + + return BatchProgress( + total: total, + completed: completed, + downloading: downloading, + verifying: verifying, + installing: installing, + queued: queued, + failed: failed, + phase: phase, + ); +}); diff --git a/lib/widgets/batch_progress_banner.dart b/lib/widgets/batch_progress_banner.dart new file mode 100644 index 0000000..a9f9844 --- /dev/null +++ b/lib/widgets/batch_progress_banner.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/theme.dart'; + +/// Banner showing batch operation progress (e.g., during "Update All"). +/// +/// All state is fully derived from operations map - no parameters needed. +/// The key insight: operations stay in map as `Completed` state after success, +/// so we can derive total, completed, and in-progress counts directly. +class BatchProgressBanner extends ConsumerWidget { + const BatchProgressBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final progress = ref.watch(batchProgressProvider); + + if (progress == null) return const SizedBox.shrink(); + + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.darkPillBackground, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // Spinner when in progress, checkmark when all complete + if (progress.hasInProgress) ...[ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + ] else if (progress.isAllComplete) ...[ + Icon(Icons.check_circle, size: 18, color: Colors.green.shade400), + const SizedBox(width: 10), + ], + + // Status text + Expanded( + child: Text( + progress.statusText, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + + // Failure count + if (progress.failed > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.shade700, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${progress.failed} failed', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index cd6bb44..c0269a2 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -204,6 +204,15 @@ class InstallButton extends ConsumerWidget { needsForceUpdate: needsForceUpdate, fontSize: fontSize, ), + + Completed() => _buildAsyncButton( + context, + ref, + text: 'Open', + onPressed: () => _openApp(context, ref), + fontSize: fontSize, + needsTrustCheck: false, + ), }; } diff --git a/spec/features/FEAT-001-package-manager.md b/spec/features/FEAT-001-package-manager.md index 52041a9..31b808a 100644 --- a/spec/features/FEAT-001-package-manager.md +++ b/spec/features/FEAT-001-package-manager.md @@ -187,6 +187,23 @@ There is no third option. Timeouts, crashes, backgrounding, network loss—all p - Queue order: best-effort insertion order (not guaranteed) - Queue advances automatically after each completion +### Batch Progress + +- When operations are in progress, show a summary banner on the updates screen +- Works for both "Update All" and individual update taps +- Banner displays current phase: downloading, verifying, or installing +- Shows completion progress: "3 of 10 updated" +- Shows count of failures if any +- "Update All" button is disabled while operations are in progress +- All progress state is fully derived from operations map: + - Successful operations transition to `Completed` state (stay in map) + - Total = operations.length (includes completed) + - Completed = count of `Completed` operations + - In-progress = Total - Completed - Failed + - Phase = derived from operation types currently active +- When all operations reach terminal state, banner shows "X of X updated ✓" +- After 3 seconds with no in-progress operations, completed operations auto-clear + ### Device Adaptation - Download concurrency adapts to device RAM to prevent crashes on low-capability devices @@ -209,6 +226,8 @@ There is no third option. Timeouts, crashes, backgrounding, network loss—all p - [ ] All errors show actionable messages - [ ] Multiple downloads queue correctly - [ ] Multiple installs proceed one at a time +- [ ] Batch progress banner shows during "Update All" +- [ ] "Update All" button disabled while operations in progress ## Notes diff --git a/work/WORK-002-batch-progress.md b/work/WORK-002-batch-progress.md new file mode 100644 index 0000000..d722d53 --- /dev/null +++ b/work/WORK-002-batch-progress.md @@ -0,0 +1,88 @@ +# WORK-002 — Batch Progress Banner + +**Feature:** FEAT-001-package-manager.md (Batch Progress section) +**Status:** Complete + +## Problem + +When user taps "Update All" or multiple individual updates, there's no summary of overall progress. User sees individual app states but no aggregate view of: +- How many apps total +- How many completed +- Current phase (downloading/verifying/installing) +- How many failed + +Also, "Update All" button remains enabled during batch operations, which could cause confusion. + +## Tasks + +- [x] 1. Add `Completed` state to `InstallOperation` + - On success, transition to `Completed` instead of removing operation + - Allows deriving completed count from operations map + +- [x] 2. Create `BatchProgress` model and provider + - All state derived from operations map (no parameters needed) + - Total = operations.length (includes Completed state) + - Completed = count of `Completed` operations + - In-progress = Total - Completed - Failed + - Phase = derived from operation types (installing > verifying > downloading) + - Failed = count of `OperationFailed` operations + +- [x] 3. Create `BatchProgressBanner` widget + - Shows only when operations exist + - Displays: "{completed} of {total} updated" + - Shows current phase with spinner + - Shows "✓" when all complete + - Shows failure count badge if > 0 + - Styled to match app theme + +- [x] 4. Integrate banner in updates screen + - Place at top of updates list, after connection status + - Banner appears/disappears based on operation state + +- [x] 5. Disable "Update All" button during operations + - Check `activeOperationsCountProvider > 0` + - Button shows disabled state (dimmed) + +- [x] 6. Auto-clear completed operations + - When all operations reach terminal state, wait 3 seconds + - Then clear all `Completed` operations (failed stay for user to see) + - Banner disappears when operations map is empty + +## Key Insight: Fully Derived State + +The key design decision: **keep completed operations in the map** instead of removing them. + +``` +Before: Installing → (removed from map) → can't count completed +After: Installing → Completed → can count completed! +``` + +This makes all progress state derivable: +```dart +total = operations.length; +completed = operations.whereType().length; +failed = operations.whereType().length; +inProgress = total - completed - failed; +``` + +Works for both "Update All" AND individual update taps. + +## UI States + +| State | Banner Shows | Button | +|-------|--------------|--------| +| No operations | Hidden | Enabled | +| Operations in progress | "2 of 5 updated • Downloading..." | Disabled | +| All complete | "5 of 5 updated ✓" (for 3 sec) | Disabled | +| After auto-clear | Hidden | Enabled | +| Some failed | "3 of 5 updated" + "2 failed" badge | Disabled | + +## Files Modified + +| File | Change | +|------|--------| +| `install_operation.dart` | Added `Completed` state, `isTerminal`/`isInProgress` extensions | +| `package_manager.dart` | Added `BatchProgress` model and `batchProgressProvider` | +| `android_package_manager.dart` | Transition to `Completed` on success, auto-clear logic | +| `batch_progress_banner.dart` | New widget | +| `updates_screen.dart` | Added banner, disabled button during ops |