Show batch progress in UI, add a Completed state for this

This commit is contained in:
franzap
2026-02-04 11:11:00 -03:00
parent 96b2c5eabc
commit 1b1650f911
8 changed files with 426 additions and 21 deletions
+41 -19
View File
@@ -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(
@@ -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<String, InstallOperation>.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) {
@@ -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,
@@ -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<BatchProgress?>((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,
);
});
+78
View File
@@ -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,
),
),
),
],
),
);
}
}
+9
View File
@@ -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,
),
};
}
+19
View File
@@ -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
+88
View File
@@ -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<Completed>().length;
failed = operations.whereType<OperationFailed>().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 |