diff --git a/packages/app_center/lib/src/l10n/app_en.arb b/packages/app_center/lib/src/l10n/app_en.arb index 3b1404523..854df90d0 100644 --- a/packages/app_center/lib/src/l10n/app_en.arb +++ b/packages/app_center/lib/src/l10n/app_en.arb @@ -50,6 +50,14 @@ } } }, + "managePageInstallingApps": "Installing now ({n})", + "@managePageInstallingApps": { + "placeholders": { + "n": { + "type": "int" + } + } + }, "productivityPageLabel": "Productivity", "developmentPageLabel": "Development", "gamesPageLabel": "Games", diff --git a/packages/app_center/lib/src/manage/manage_model.dart b/packages/app_center/lib/src/manage/manage_model.dart index 5eaab2603..6cd487b52 100644 --- a/packages/app_center/lib/src/manage/manage_model.dart +++ b/packages/app_center/lib/src/manage/manage_model.dart @@ -1,4 +1,5 @@ import 'package:app_center/snapd.dart'; +import 'package:app_center/src/snapd/logger.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -25,6 +26,8 @@ class ManageModel extends ChangeNotifier { List? _installedSnaps; List? _refreshableSnapNames; + Map + snapsWithInprogressChange = {}; bool _isRefreshable(Snap snap) => updatesModel.hasUpdate(snap.name); Iterable get refreshableSnaps => @@ -59,4 +62,20 @@ class ManageModel extends ChangeNotifier { _installedSnaps = await snapd.getSnaps().then( (snaps) => snaps.sortedBy((snap) => snap.titleOrName.toLowerCase())); } + + Future handleSnapChange(Snap snap, SnapdChange change) async { + _state = await AsyncValue.guard(() async { + log.debug( + 'handleSnapChange: ${snap.name} with changeId ${change.id} for kind: ${change.kind}, is ${change.status}'); + if (change.status == 'Done') { + snapsWithInprogressChange.remove(snap.name); + } else { + snapsWithInprogressChange.putIfAbsent( + snap.name, () => (snap: snap, snapdChange: change)); + } + + await _getInstalledSnaps(); + notifyListeners(); + }); + } } diff --git a/packages/app_center/lib/src/manage/manage_page.dart b/packages/app_center/lib/src/manage/manage_page.dart index 1ff901b9b..691ed6504 100644 --- a/packages/app_center/lib/src/manage/manage_page.dart +++ b/packages/app_center/lib/src/manage/manage_page.dart @@ -130,6 +130,63 @@ class _ManageView extends ConsumerWidget { showUpdateButton: true, ), ), + if (manageModel.snapsWithInprogressChange.isNotEmpty) + SliverList.list( + children: [ + const SizedBox(height: 48), + Builder(builder: (context) { + final compact = ResponsiveLayout.of(context).type == + ResponsiveLayoutType.small; + return Flex( + direction: compact ? Axis.vertical : Axis.horizontal, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: compact + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.managePageInstallingApps( + manageModel.snapsWithInprogressChange.length), + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.w500), + ), + ], + ); + }), + const SizedBox(height: 24), + if (manageModel.snapsWithInprogressChange.isEmpty) + Text( + l10n.managePageNoUpdatesAvailableDescription, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + if (manageModel.snapsWithInprogressChange.isNotEmpty) + SliverList.builder( + itemCount: manageModel.snapsWithInprogressChange.length, + itemBuilder: (context, index) { + final snapDetails = manageModel.snapsWithInprogressChange[ + manageModel.snapsWithInprogressChange.keys + .toList() + .elementAt(index)]!; + + return _ManageSnapTile( + snapdChangeId: snapDetails.snapdChange.id, + snap: snapDetails.snap, + position: index == + (manageModel.snapsWithInprogressChange.length - 1) + ? index == 0 + ? ManageTilePosition.single + : ManageTilePosition.last + : index == 0 + ? ManageTilePosition.first + : ManageTilePosition.middle, + ); + }, + ), SliverList.list(children: [ const SizedBox(height: 48), Text( @@ -330,11 +387,13 @@ enum ManageTilePosition { first, middle, last, single } class _ManageSnapTile extends ConsumerWidget { const _ManageSnapTile({ required this.snap, + this.snapdChangeId, this.position = ManageTilePosition.middle, this.showUpdateButton = false, }); final Snap snap; + final String? snapdChangeId; final ManageTilePosition position; final bool showUpdateButton; @@ -347,6 +406,13 @@ class _ManageSnapTile extends ConsumerWidget { ? DateTime.now().difference(snap.installDate!).inDays : null; + SnapdChange? change; + if (snapdChangeId != null) { + change = ref + .watch(changeProvider(snapdChangeId)) + .whenOrNull(data: (data) => data); + } + return ListTile( key: ValueKey(snap.id), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -405,6 +471,30 @@ class _ManageSnapTile extends ConsumerWidget { ) : const SizedBox(), ), + if (change?.id != null) + Expanded( + child: Row( + children: [ + SizedBox.square( + dimension: 16, + child: YaruCircularProgressIndicator( + value: change?.progress, + strokeWidth: 2, + ), + ), + if (change != null) ...[ + const SizedBox(width: 8), + Flexible( + child: Text( + change.localize(l10n) ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ] + ], + ), + ), Expanded( child: snap.installedSize != null ? Text( diff --git a/packages/app_center/lib/src/snapd/snap_model.dart b/packages/app_center/lib/src/snapd/snap_model.dart index 4cbe436c2..597186ece 100644 --- a/packages/app_center/lib/src/snapd/snap_model.dart +++ b/packages/app_center/lib/src/snapd/snap_model.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:app_center/snapd.dart'; +import 'package:app_center/src/manage/manage_model.dart'; import 'package:app_center/src/snapd/logger.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; @@ -8,16 +9,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:snapd/snapd.dart'; import 'package:ubuntu_service/ubuntu_service.dart'; -final snapModelProvider = - ChangeNotifierProvider.family.autoDispose( +final snapModelProvider = ChangeNotifierProvider.family( (ref, snapName) => SnapModel( + ref: ref, snapd: getService(), snapName: snapName, )..init(), ); final progressProvider = - StreamProvider.family.autoDispose>((ref, ids) { + StreamProvider.family>((ref, ids) { final snapd = getService(); final streamController = StreamController.broadcast(); @@ -38,19 +39,17 @@ final progressProvider = return streamController.stream; }); -final changeProvider = - StreamProvider.family.autoDispose((ref, id) { +final changeProvider = StreamProvider.family((ref, id) { if (id == null) return const Stream.empty(); return getService().watchChange(id); }); class SnapModel extends ChangeNotifier { - SnapModel({ - required this.snapd, - required this.snapName, - }) : _state = const AsyncValue.loading(); + SnapModel({required this.snapd, required this.snapName, this.ref}) + : _state = const AsyncValue.loading(); final SnapdService snapd; final String snapName; + final ChangeNotifierProviderRef? ref; String? get activeChangeId => _activeChangeId; String? _activeChangeId; @@ -148,6 +147,8 @@ class SnapModel extends ChangeNotifier { } Future _activeChangeListener(SnapdChange change) async { + await ref?.read(manageModelProvider).handleSnapChange(snap, change); + if (change.ready) { log.debug('Change $_activeChangeId for $snapName done'); _setActiveChange(null); diff --git a/packages/app_center/test/manage_page_test.dart b/packages/app_center/test/manage_page_test.dart index 3f38b73fc..1ddc77a58 100644 --- a/packages/app_center/test/manage_page_test.dart +++ b/packages/app_center/test/manage_page_test.dart @@ -36,6 +36,21 @@ void main() { ), ]; + final snapsWithInprogressChange = + {}; + snapsWithInprogressChange.putIfAbsent( + 'testsnap4', + () => ( + snap: const Snap( + name: 'testsnap4', + title: 'Snap that is being installed', + version: '2.0', + channel: 'latest/stable', + ), + snapdChange: SnapdChange(spawnTime: DateTime.now()) + ), + ); + final snapModel = createMockSnapModel( hasUpdate: true, localSnap: refreshableSnaps[0], @@ -74,6 +89,30 @@ void main() { findsOneWidget); }); + testWidgets('list installing snaps', (tester) async { + await tester.pumpApp( + (_) => ProviderScope( + overrides: [ + launchProvider.overrideWith((_, __) => createMockSnapLauncher()), + manageModelProvider.overrideWith( + (_) => createMockManageModel( + snapsWithInprogressChange: snapsWithInprogressChange), + ), + showLocalSystemAppsProvider.overrideWith((ref) => true), + updatesModelProvider.overrideWith((_) => createMockUpdatesModel()) + ], + child: const ManagePage(), + ), + ); + + final testTile = find.snapTile('Snap that is being installed'); + expect(testTile, findsOneWidget); + expect(find.descendant(of: testTile, matching: find.text('2.0')), + findsOneWidget); + expect(find.descendant(of: testTile, matching: find.text('latest/stable')), + findsOneWidget); + }); + testWidgets('launch desktop snap', (tester) async { final snapLauncher = createMockSnapLauncher(isLaunchable: true); await tester.pumpApp( diff --git a/packages/app_center/test/test_utils.dart b/packages/app_center/test/test_utils.dart index 96983cd1a..c9cf3ba6b 100644 --- a/packages/app_center/test/test_utils.dart +++ b/packages/app_center/test/test_utils.dart @@ -163,6 +163,8 @@ DebModel createMockDebModel({ ManageModel createMockManageModel({ Iterable? refreshableSnaps, Iterable? nonRefreshableSnaps, + Map? + snapsWithInprogressChange, AsyncValue? state, }) { final model = MockManageModel(); @@ -171,6 +173,8 @@ ManageModel createMockManageModel({ .thenReturn(refreshableSnaps ?? const Iterable.empty()); when(model.nonRefreshableSnaps) .thenReturn(nonRefreshableSnaps ?? const Iterable.empty()); + when(model.snapsWithInprogressChange) + .thenReturn(snapsWithInprogressChange ?? {}); return model; } diff --git a/packages/app_center/test/test_utils.mocks.dart b/packages/app_center/test/test_utils.mocks.dart index c2686d8f2..d03f096b5 100644 --- a/packages/app_center/test/test_utils.mocks.dart +++ b/packages/app_center/test/test_utils.mocks.dart @@ -737,6 +737,26 @@ class MockManageModel extends _i1.Mock implements _i17.ManageModel { ), ) as _i6.UpdatesModel); + @override + Map + get snapsWithInprogressChange => (super.noSuchMethod( + Invocation.getter(#snapsWithInprogressChange), + returnValue: {}, + ) as Map); + + @override + set snapsWithInprogressChange( + Map? + _snapsWithInprogressChange) => + super.noSuchMethod( + Invocation.setter( + #snapsWithInprogressChange, + _snapsWithInprogressChange, + ), + returnValueForMissingStub: null, + ); + @override _i5.AsyncValue get state => (super.noSuchMethod( Invocation.getter(#state), @@ -783,6 +803,23 @@ class MockManageModel extends _i1.Mock implements _i17.ManageModel { returnValueForMissingStub: null, ); + @override + _i14.Future handleSnapChange( + _i2.Snap? snap, + _i2.SnapdChange? change, + ) => + (super.noSuchMethod( + Invocation.method( + #handleSnapChange, + [ + snap, + change, + ], + ), + returnValue: _i14.Future.value(), + returnValueForMissingStub: _i14.Future.value(), + ) as _i14.Future); + @override void addListener(_i15.VoidCallback? listener) => super.noSuchMethod( Invocation.method(