mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Show batch progress in UI, add a Completed state for this
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
Reference in New Issue
Block a user