diff --git a/app/lib/bloc/progress.dart b/app/lib/bloc/progress.dart new file mode 100644 index 00000000..ca421d8d --- /dev/null +++ b/app/lib/bloc/progress.dart @@ -0,0 +1,57 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:logging/logging.dart'; +import 'package:to_string/to_string.dart'; + +part 'progress.g.dart'; + +abstract class ProgressBlocEvent { + const ProgressBlocEvent(); +} + +@toString +class ProgressBlocUpdate extends ProgressBlocEvent { + const ProgressBlocUpdate(this.progress, [this.text]); + + @override + String toString() => _$toString(); + + final double progress; + final String? text; +} + +@toString +class ProgressBlocState with EquatableMixin { + const ProgressBlocState(this.progress, this.text); + + @override + String toString() => _$toString(); + + @override + List get props => [progress, text]; + + final double progress; + final String? text; +} + +/// A generic bloc to bubble progress update for some events +class ProgressBloc extends Bloc { + ProgressBloc() : super(const ProgressBlocState(0, null)) { + on(_onEvent); + } + + Future _onEvent( + ProgressBlocEvent ev, Emitter emit) async { + _log.info("[_onEvent] $ev"); + if (ev is ProgressBlocUpdate) { + await _onEventUpdate(ev, emit); + } + } + + Future _onEventUpdate( + ProgressBlocUpdate ev, Emitter emit) async { + emit(ProgressBlocState(ev.progress, ev.text)); + } + + static final _log = Logger("bloc.progress.ProgressBloc"); +} diff --git a/app/lib/bloc/progress.g.dart b/app/lib/bloc/progress.g.dart new file mode 100644 index 00000000..e067d6a3 --- /dev/null +++ b/app/lib/bloc/progress.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'progress.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$ProgressBlocUpdateToString on ProgressBlocUpdate { + String _$toString() { + return "ProgressBlocUpdate {progress: $progress, text: $text}"; + } +} + +extension _$ProgressBlocStateToString on ProgressBlocState { + String _$toString() { + return "ProgressBlocState {progress: $progress, text: $text}"; + } +} diff --git a/app/lib/bloc/scan_account_dir.dart b/app/lib/bloc/scan_account_dir.dart index fb0217cb..273e023f 100644 --- a/app/lib/bloc/scan_account_dir.dart +++ b/app/lib/bloc/scan_account_dir.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util; +import 'package:nc_photos/bloc/progress.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; @@ -15,6 +17,7 @@ import 'package:nc_photos/event/native_event.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/progress_util.dart'; import 'package:nc_photos/throttler.dart'; import 'package:nc_photos/use_case/ls.dart'; import 'package:nc_photos/use_case/scan_dir.dart'; @@ -25,22 +28,31 @@ abstract class ScanAccountDirBlocEvent { const ScanAccountDirBlocEvent(); } -class ScanAccountDirBlocQueryBase extends ScanAccountDirBlocEvent { - const ScanAccountDirBlocQueryBase(); +abstract class ScanAccountDirBlocQueryBase extends ScanAccountDirBlocEvent { + const ScanAccountDirBlocQueryBase({ + this.progressBloc, + }); @override toString() { return "$runtimeType {" "}"; } + + /// Get notified about the query progress + final ProgressBloc? progressBloc; } class ScanAccountDirBlocQuery extends ScanAccountDirBlocQueryBase { - const ScanAccountDirBlocQuery(); + const ScanAccountDirBlocQuery({ + super.progressBloc, + }); } class ScanAccountDirBlocRefresh extends ScanAccountDirBlocQueryBase { - const ScanAccountDirBlocRefresh(); + const ScanAccountDirBlocRefresh({ + super.progressBloc, + }); } /// An external event has happened and may affect the state of this bloc @@ -72,7 +84,12 @@ class ScanAccountDirBlocInit extends ScanAccountDirBlocState { } class ScanAccountDirBlocLoading extends ScanAccountDirBlocState { - const ScanAccountDirBlocLoading(List files) : super(files); + const ScanAccountDirBlocLoading( + List files, { + this.isInitialLoad = false, + }) : super(files); + + final bool isInitialLoad; } class ScanAccountDirBlocSuccess extends ScanAccountDirBlocState { @@ -207,10 +224,22 @@ class ScanAccountDirBloc emit(ScanAccountDirBlocLoading(cacheFiles)); } + if (!hasContent && cacheFiles.isEmpty) { + emit(const ScanAccountDirBlocLoading([], isInitialLoad: true)); + } + stopwatch.reset(); final bool hasUpdate; try { - hasUpdate = await _syncOnline(ev); + hasUpdate = await _syncOnline( + ev, + onProgressUpdate: (value) { + if (ev.progressBloc?.isClosed == false) { + ev.progressBloc! + .add(ProgressBlocUpdate(value.progress, value.text)); + } + }, + ); } catch (e, stackTrace) { _log.shout("[_onEventQuery] Exception while request", e, stackTrace); emit(ScanAccountDirBlocFailure(cacheFiles, e)); @@ -230,7 +259,10 @@ class ScanAccountDirBloc } } - Future _syncOnline(ScanAccountDirBlocQueryBase ev) async { + Future _syncOnline( + ScanAccountDirBlocQueryBase ev, { + ValueChanged? onProgressUpdate, + }) async { final settings = AccountPref.of(account); final shareDir = File(path: file_util.unstripPath(account, settings.getShareFolderOr())); @@ -238,11 +270,20 @@ class ScanAccountDirBloc bool hasUpdate = false; _c.touchManager.clearTouchCache(); + final progress = IntProgress(account.roots.length); for (final r in account.roots) { final dirPath = file_util.unstripPath(account, r); - hasUpdate |= await SyncDir(_c)(account, dirPath); + hasUpdate |= await SyncDir(_c)( + account, + dirPath, + onProgressUpdate: (value) { + final merged = progress.progress + progress.step * value.progress; + onProgressUpdate?.call(Progress(merged, value.text)); + }, + ); isShareDirIncluded |= file_util.isOrUnderDir(shareDir, File(path: dirPath)); + progress.next(); } if (!isShareDirIncluded) { diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 83aaeb89..c5d57620 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1489,6 +1489,11 @@ "@imageSaveOptionDialogServerButtonLabel": { "description": "Save the image on your Nextcloud server" }, + "initialSyncMessage": "Syncing with your server for the first time", + "@initialSyncMessage": { + "description": "After adding a new account, the app need to sync with the server before showing anything. This message will be shown on screen instead with a proper progress bar and the folder being synced." + }, + "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { "description": "Error message when server responds with HTTP401" diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index dcc8f67f..92297f15 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -179,6 +179,7 @@ "imageSaveOptionDialogContent", "imageSaveOptionDialogDeviceButtonLabel", "imageSaveOptionDialogServerButtonLabel", + "initialSyncMessage", "errorAlbumDowngrade" ], @@ -374,6 +375,7 @@ "imageSaveOptionDialogContent", "imageSaveOptionDialogDeviceButtonLabel", "imageSaveOptionDialogServerButtonLabel", + "initialSyncMessage", "errorAlbumDowngrade" ], @@ -459,7 +461,8 @@ "imageSaveOptionDialogTitle", "imageSaveOptionDialogContent", "imageSaveOptionDialogDeviceButtonLabel", - "imageSaveOptionDialogServerButtonLabel" + "imageSaveOptionDialogServerButtonLabel", + "initialSyncMessage" ], "es": [ @@ -470,7 +473,8 @@ "settingsSeedColorDescription", "settingsSeedColorPickerTitle", "rootPickerSkipConfirmationDialogContent2", - "slideshowSetupDialogReverseTitle" + "slideshowSetupDialogReverseTitle", + "initialSyncMessage" ], "fi": [ @@ -478,7 +482,8 @@ "signInHeaderText2", "settingsSeedColorTitle", "settingsSeedColorDescription", - "settingsSeedColorPickerTitle" + "settingsSeedColorPickerTitle", + "initialSyncMessage" ], "fr": [ @@ -583,7 +588,8 @@ "imageSaveOptionDialogTitle", "imageSaveOptionDialogContent", "imageSaveOptionDialogDeviceButtonLabel", - "imageSaveOptionDialogServerButtonLabel" + "imageSaveOptionDialogServerButtonLabel", + "initialSyncMessage" ], "pl": [ @@ -705,7 +711,8 @@ "imageSaveOptionDialogTitle", "imageSaveOptionDialogContent", "imageSaveOptionDialogDeviceButtonLabel", - "imageSaveOptionDialogServerButtonLabel" + "imageSaveOptionDialogServerButtonLabel", + "initialSyncMessage" ], "pt": [ @@ -806,7 +813,8 @@ "imageSaveOptionDialogTitle", "imageSaveOptionDialogContent", "imageSaveOptionDialogDeviceButtonLabel", - "imageSaveOptionDialogServerButtonLabel" + "imageSaveOptionDialogServerButtonLabel", + "initialSyncMessage" ], "ru": [ @@ -907,7 +915,8 @@ "imageSaveOptionDialogTitle", "imageSaveOptionDialogContent", "imageSaveOptionDialogDeviceButtonLabel", - "imageSaveOptionDialogServerButtonLabel" + "imageSaveOptionDialogServerButtonLabel", + "initialSyncMessage" ], "zh": [ @@ -1008,7 +1017,8 @@ "imageSaveOptionDialogTitle", "imageSaveOptionDialogContent", "imageSaveOptionDialogDeviceButtonLabel", - "imageSaveOptionDialogServerButtonLabel" + "imageSaveOptionDialogServerButtonLabel", + "initialSyncMessage" ], "zh_Hant": [ @@ -1109,6 +1119,7 @@ "imageSaveOptionDialogTitle", "imageSaveOptionDialogContent", "imageSaveOptionDialogDeviceButtonLabel", - "imageSaveOptionDialogServerButtonLabel" + "imageSaveOptionDialogServerButtonLabel", + "initialSyncMessage" ] } diff --git a/app/lib/progress_util.dart b/app/lib/progress_util.dart new file mode 100644 index 00000000..a6e68c0b --- /dev/null +++ b/app/lib/progress_util.dart @@ -0,0 +1,34 @@ +import 'dart:math' as math; + +import 'package:to_string/to_string.dart'; + +part 'progress_util.g.dart'; + +@toString +class IntProgress { + IntProgress(this.max) : step = max <= 0 ? 1 : 1 / max; + + void next() { + _current = math.min(_current + 1, max); + } + + double get progress => max <= 0 ? 1 : _current / max; + + @override + String toString() => _$toString(); + + final int max; + final double step; + var _current = 0; +} + +@ToString(ignoreNull: true) +class Progress { + const Progress(this.progress, [this.text]); + + @override + String toString() => _$toString(); + + final double progress; + final String? text; +} diff --git a/app/lib/progress_util.g.dart b/app/lib/progress_util.g.dart new file mode 100644 index 00000000..e47de04b --- /dev/null +++ b/app/lib/progress_util.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'progress_util.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$IntProgressToString on IntProgress { + String _$toString() { + return "IntProgress {max: $max, step: $step, _current: $_current}"; + } +} + +extension _$ProgressToString on Progress { + String _$toString() { + return "Progress {progress: $progress, ${text == null ? "" : "text: $text, "}}"; + } +} diff --git a/app/lib/use_case/sync_dir.dart b/app/lib/use_case/sync_dir.dart index 8645a33a..55ff7e48 100644 --- a/app/lib/use_case/sync_dir.dart +++ b/app/lib/use_case/sync_dir.dart @@ -1,3 +1,4 @@ +import 'package:flutter/rendering.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/debug_util.dart'; @@ -7,6 +8,7 @@ import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/progress_util.dart'; import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; import 'package:nc_photos/use_case/ls_single_file.dart'; import 'package:tuple/tuple.dart'; @@ -26,12 +28,18 @@ class SyncDir { Account account, String dirPath, { bool isRecursive = true, + ValueChanged? onProgressUpdate, }) async { final dirCache = await _queryAllDirEtags(account, dirPath); final remoteRoot = await LsSingleFile(_c.withRemoteFileRepo())(account, dirPath); - return await _syncDir(account, remoteRoot, dirCache, - isRecursive: isRecursive); + return await _syncDir( + account, + remoteRoot, + dirCache, + isRecursive: isRecursive, + onProgressUpdate: onProgressUpdate, + ); } Future _syncDir( @@ -39,6 +47,7 @@ class SyncDir { File remoteDir, Map dirCache, { required bool isRecursive, + ValueChanged? onProgressUpdate, }) async { final status = await _checkContentUpdated(account, remoteDir, dirCache); if (!status.item1) { @@ -52,16 +61,32 @@ class SyncDir { if (!isRecursive) { return true; } - for (final d in children.where((c) => - c.isCollection == true && - !remoteDir.compareServerIdentity(c) && - !c.path.endsWith(remote_storage_util.getRemoteStorageDir(account)))) { + final subDirs = children + .where((f) => + f.isCollection == true && + !remoteDir.compareServerIdentity(f) && + !f.path.endsWith(remote_storage_util.getRemoteStorageDir(account))) + .toList(); + final progress = IntProgress(subDirs.length); + for (final d in subDirs) { + onProgressUpdate + ?.call(Progress(progress.progress, d.strippedPathWithEmpty)); try { - await _syncDir(account, d, dirCache, isRecursive: isRecursive); + await _syncDir( + account, + d, + dirCache, + isRecursive: isRecursive, + onProgressUpdate: (value) { + final merged = progress.progress + progress.step * value.progress; + onProgressUpdate?.call(Progress(merged, value.text)); + }, + ); } catch (e, stackTrace) { _log.severe("[_syncDir] Failed while _syncDir: ${logFilename(d.path)}", e, stackTrace); } + progress.next(); } return true; } diff --git a/app/lib/widget/home_photos.dart b/app/lib/widget/home_photos.dart index 47dd4608..8a9ad19c 100644 --- a/app/lib/widget/home_photos.dart +++ b/app/lib/widget/home_photos.dart @@ -14,6 +14,7 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util; +import 'package:nc_photos/bloc/progress.dart'; import 'package:nc_photos/bloc/scan_account_dir.dart'; import 'package:nc_photos/compute_queue.dart'; import 'package:nc_photos/di_container.dart'; @@ -95,10 +96,7 @@ class _HomePhotosState extends State return BlocListener( bloc: _bloc, listener: (context, state) => _onStateChange(context, state), - child: BlocBuilder( - bloc: _bloc, - builder: (context, state) => _buildContent(context, state), - ), + child: _buildContent(context), ); } @@ -148,7 +146,7 @@ class _HomePhotosState extends State } } - Widget _buildContent(BuildContext context, ScanAccountDirBlocState state) { + Widget _buildContent(BuildContext context) { return LayoutBuilder(builder: (context, constraints) { final scrollExtent = _getScrollViewExtent(context, constraints); return Stack( @@ -183,21 +181,32 @@ class _HomePhotosState extends State .isEnableMemoryAlbumOr(true) && _smartAlbums.isNotEmpty) _buildSmartAlbumList(context), - buildItemStreamList( - maxCrossAxisExtent: _thumbSize.toDouble(), - onMaxExtentChanged: (value) { - setState(() { - _itemListMaxExtent = value; - }); + BlocBuilder( + bloc: _bloc, + builder: (context, state) { + if (_isInitialSync(state)) { + return _InitialLoadingProgress( + progressBloc: _queryProgressBloc, + ); + } else { + return buildItemStreamList( + maxCrossAxisExtent: _thumbSize.toDouble(), + onMaxExtentChanged: (value) { + setState(() { + _itemListMaxExtent = value; + }); + }, + isEnableVisibilityCallback: true, + ); + } }, - isEnableVisibilityCallback: true, ), SliverToBoxAdapter( child: SizedBox( height: _calcBottomAppBarExtent(context), ), ), - ].whereType().toList(), + ].whereNotNull().toList(), ), ), ), @@ -279,15 +288,24 @@ class _HomePhotosState extends State } Widget _buildNormalAppBar(BuildContext context) { - return BlocBuilder( + return BlocBuilder( bloc: _bloc, - buildWhen: (previous, current) => - previous is ScanAccountDirBlocLoading != - current is ScanAccountDirBlocLoading, + buildWhen: (previous, current) { + if (previous is ScanAccountDirBlocLoading && + current is ScanAccountDirBlocLoading) { + // both loading, check if initial flag changed + return previous.isInitialLoad != current.isInitialLoad; + } else { + // check if any one is loading == state changed from/to loading + return previous is ScanAccountDirBlocLoading || + current is ScanAccountDirBlocLoading; + } + }, builder: (context, state) { return HomeSliverAppBar( account: widget.account, - isShowProgressIcon: (state is ScanAccountDirBlocLoading || + isShowProgressIcon: !_isInitialSync(state) && + (state is ScanAccountDirBlocLoading || _buildItemQueue.isProcessing) && !_isRefreshIndicatorActive, actions: [ @@ -620,7 +638,9 @@ class _HomePhotosState extends State } void _reqQuery() { - _bloc.add(const ScanAccountDirBlocQuery()); + _bloc.add(ScanAccountDirBlocQuery( + progressBloc: _queryProgressBloc, + )); } void _reqRefresh() { @@ -691,6 +711,11 @@ class _HomePhotosState extends State } } + bool _isInitialSync(ScanAccountDirBlocState state) => + state is ScanAccountDirBlocLoading && + state.files.isEmpty && + state.isInitialLoad; + double _calcAppBarExtent(BuildContext context) => MediaQuery.of(context).padding.top + kToolbarHeight; @@ -728,6 +753,7 @@ class _HomePhotosState extends State } late final _bloc = ScanAccountDirBloc.of(widget.account); + late final _queryProgressBloc = ProgressBloc(); var _backingFiles = []; var _smartAlbums = []; @@ -1087,3 +1113,52 @@ class _VisibleItem implements Comparable<_VisibleItem> { final int index; final SelectableItem item; } + +class _InitialLoadingProgress extends StatelessWidget { + const _InitialLoadingProgress({ + required this.progressBloc, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: progressBloc, + buildWhen: (previous, current) => previous != current, + builder: (context, state) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 56, 16, 0), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: Theme.of(context).widthLimitedContentMaxWidth, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + L10n.global().initialSyncMessage, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: state.progress == 0 ? null : state.progress, + ), + const SizedBox(height: 8), + Text( + state.text ?? "", + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + final ProgressBloc progressBloc; +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 1eb05b06..dbea301b 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "41.0.0" + version: "47.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "4.2.0" + version: "4.7.0" analyzer_plugin: dependency: transitive description: @@ -121,7 +121,7 @@ packages: name: build url: "https://pub.dartlang.org" source: hosted - version: "2.3.0" + version: "2.3.1" build_config: dependency: transitive description: @@ -1154,7 +1154,7 @@ packages: name: source_gen url: "https://pub.dartlang.org" source: hosted - version: "1.2.2" + version: "1.2.6" source_map_stack_trace: dependency: transitive description: @@ -1281,6 +1281,15 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + to_string: + dependency: "direct dev" + description: + path: "." + ref: "2432bd99c5c8c90b11d7e691992514e312cee0fe" + resolved-ref: "2432bd99c5c8c90b11d7e691992514e312cee0fe" + url: "https://gitlab.com/nkming2/dart-to-string" + source: git + version: "1.0.0" tuple: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index dd0e51dd..b9a1d9da 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -126,6 +126,10 @@ dev_dependencies: sdk: flutter # integration_test: # sdk: flutter + to_string: + git: + url: https://gitlab.com/nkming2/dart-to-string + ref: 2432bd99c5c8c90b11d7e691992514e312cee0fe # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec