diff --git a/app/lib/controller/files_controller.dart b/app/lib/controller/files_controller.dart index 9ef308ea..c858dca2 100644 --- a/app/lib/controller/files_controller.dart +++ b/app/lib/controller/files_controller.dart @@ -10,9 +10,11 @@ import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/progress_util.dart'; import 'package:nc_photos/rx_extension.dart'; import 'package:nc_photos/use_case/file/list_file.dart'; import 'package:nc_photos/use_case/remove.dart'; +import 'package:nc_photos/use_case/sync_dir.dart'; import 'package:nc_photos/use_case/update_property.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/lazy.dart'; @@ -54,27 +56,52 @@ class FilesController { ValueStream get stream { if (!_isDataStreamInited) { _isDataStreamInited = true; - unawaited(_load()); + _load(); } return _dataStreamController.stream; } - Future reload() async { - var results = []; - final completer = Completer(); - ListFile(_c)( - account, - file_util.unstripPath(account, accountPrefController.shareFolder.value), - ).listen( - (ev) { - results = ev; - }, - onError: _dataStreamController.addError, - onDone: () => completer.complete(), - ); - await completer.future; - _dataStreamController - .add(_convertListResultsToEvent(results, hasNext: false)); + Future syncRemote({ + void Function(Progress progress)? onProgressUpdate, + }) async { + if (_isSyncing) { + _log.fine("[syncRemote] Skipped as another sync running"); + return; + } + _isSyncing = true; + try { + final shareDir = File( + path: file_util.unstripPath( + account, accountPrefController.shareFolder.value), + ); + var isShareDirIncluded = false; + + _c.touchManager.clearTouchCache(); + final progress = IntProgress(account.roots.length); + for (final r in account.roots) { + final dirPath = file_util.unstripPath(account, r); + await SyncDir(_c)( + account, + dirPath, + onProgressUpdate: (value) { + final merged = progress.progress + progress.step * value.progress; + onProgressUpdate?.call(Progress(merged, value.text)); + }, + ); + isShareDirIncluded |= + file_util.isOrUnderDirPath(shareDir.path, dirPath); + progress.next(); + } + + if (!isShareDirIncluded) { + _log.info("[syncRemote] Explicitly scanning share folder"); + await SyncDir(_c)(account, shareDir.path, isRecursive: false); + } + // load the synced content to stream + unawaited(_reload()); + } finally { + _isSyncing = false; + } } /// Update files property and return number of files updated @@ -227,6 +254,24 @@ class FilesController { _dataStreamController.add(lastData.copyWith(hasNext: false)); } + Future _reload() async { + var results = []; + final completer = Completer(); + ListFile(_c)( + account, + file_util.unstripPath(account, accountPrefController.shareFolder.value), + ).listen( + (ev) { + results = ev; + }, + onError: _dataStreamController.addError, + onDone: () => completer.complete(), + ); + await completer.future; + _dataStreamController + .add(_convertListResultsToEvent(results, hasNext: false)); + } + _FilesStreamEvent _convertListResultsToEvent( List results, { required bool hasNext, @@ -252,6 +297,7 @@ class FilesController { ); final _mutex = Mutex(); + var _isSyncing = false; } @toString diff --git a/app/lib/widget/home_photos/app_bar.dart b/app/lib/widget/home_photos/app_bar.dart index d5a6c57d..3c3ac7e0 100644 --- a/app/lib/widget/home_photos/app_bar.dart +++ b/app/lib/widget/home_photos/app_bar.dart @@ -5,11 +5,11 @@ class _AppBar extends StatelessWidget { @override Widget build(BuildContext context) { - return _BlocBuilder( - buildWhen: (previous, current) => previous.isLoading != current.isLoading, - builder: (context, state) => HomeSliverAppBar( + return _BlocSelector( + selector: (state) => state.isLoading || state.syncProgress != null, + builder: (context, isProcessing) => HomeSliverAppBar( account: context.bloc.account, - isShowProgressIcon: state.isLoading, + isShowProgressIcon: isProcessing, ), ); } diff --git a/app/lib/widget/home_photos/bloc.dart b/app/lib/widget/home_photos/bloc.dart index 956cf645..bc55ea59 100644 --- a/app/lib/widget/home_photos/bloc.dart +++ b/app/lib/widget/home_photos/bloc.dart @@ -29,6 +29,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { on<_RemoveVisibleItem>(_onRemoveVisibleItem); on<_SetContentListMaxExtent>(_onSetContentListMaxExtent); + on<_SetSyncProgress>(_onSetSyncProgress); on<_StartScaling>(_onStartScaling); on<_EndScaling>(_onEndScaling); @@ -60,7 +61,8 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { currentState = currentState as _State; nextState = nextState as _State; return currentState.scale == nextState.scale && - currentState.visibleItems == nextState.visibleItems; + currentState.visibleItems == nextState.visibleItems && + currentState.syncProgress == nextState.syncProgress; }; @override @@ -80,10 +82,16 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { _log.info(ev); return emit.forEach( controller.stream, - onData: (data) => state.copyWith( - files: data.data, - isLoading: data.hasNext || _itemTransformerQueue.isProcessing, - ), + onData: (data) { + if (_isInitialLoad && !data.hasNext) { + _isInitialLoad = false; + _syncRemote(); + } + return state.copyWith( + files: data.data, + isLoading: data.hasNext || _itemTransformerQueue.isProcessing, + ); + }, onError: (e, stackTrace) { _log.severe("[_onLoad] Uncaught exception", e, stackTrace); return state.copyWith( @@ -96,7 +104,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { void _onReload(_Reload ev, Emitter<_State> emit) { _log.info(ev); - unawaited(controller.reload()); + _syncRemote(); } void _onTransformItems(_TransformItems ev, Emitter<_State> emit) { @@ -204,6 +212,11 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { emit(state.copyWith(contentListMaxExtent: ev.value)); } + void _onSetSyncProgress(_SetSyncProgress ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(syncProgress: ev.progress)); + } + void _onStartScaling(_StartScaling ev, Emitter<_State> emit) { _log.info(ev); } @@ -264,6 +277,22 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { ); } + void _syncRemote() { + final stopwatch = Stopwatch()..start(); + controller.syncRemote( + onProgressUpdate: (progress) { + if (!isClosed) { + add(_SetSyncProgress(progress)); + } + }, + ).whenComplete(() { + if (!isClosed) { + add(const _SetSyncProgress(null)); + } + _log.info("[_syncRemote] Elapsed time: ${stopwatch.elapsedMilliseconds}ms"); + }); + } + void _clearSelection(Emitter<_State> emit) { emit(state.copyWith(selectedItems: const {})); } @@ -279,6 +308,7 @@ class _Bloc extends Bloc<_Event, _State> with BlocLogger { ComputeQueue<_ItemTransformerArgument, _ItemTransformerResult>(); final _subscriptions = []; var _isHandlingError = false; + var _isInitialLoad = true; } _ItemTransformerResult _buildItem(_ItemTransformerArgument arg) { diff --git a/app/lib/widget/home_photos/state_event.dart b/app/lib/widget/home_photos/state_event.dart index 77a36b01..df020711 100644 --- a/app/lib/widget/home_photos/state_event.dart +++ b/app/lib/widget/home_photos/state_event.dart @@ -12,6 +12,7 @@ class _State { required this.isEnableMemoryCollection, required this.memoryCollections, this.contentListMaxExtent, + this.syncProgress, required this.zoom, this.scale, this.error, @@ -45,6 +46,7 @@ class _State { final List memoryCollections; final double? contentListMaxExtent; + final Progress? syncProgress; final int zoom; final double? scale; @@ -170,6 +172,16 @@ class _SetContentListMaxExtent implements _Event { final double? value; } +@toString +class _SetSyncProgress implements _Event { + const _SetSyncProgress(this.progress); + + @override + String toString() => _$toString(); + + final Progress? progress; +} + @toString class _StartScaling implements _Event { const _StartScaling(); diff --git a/app/lib/widget/home_photos2.dart b/app/lib/widget/home_photos2.dart index 638e605d..d5a59c6d 100644 --- a/app/lib/widget/home_photos2.dart +++ b/app/lib/widget/home_photos2.dart @@ -29,6 +29,7 @@ import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/flutter_util.dart' as flutter_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/language_util.dart' as language_util; +import 'package:nc_photos/progress_util.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme/dimension.dart'; @@ -139,123 +140,200 @@ class _WrappedHomePhotosState extends State<_WrappedHomePhotos> { }, ), ], - child: FingerListener( - onFingerChanged: (finger) { - setState(() { - _finger = finger; - }); + child: _BlocSelector( + selector: (state) => + state.files.isEmpty && + state.syncProgress != null, + builder: (context, isInitialSyncing) { + if (isInitialSyncing) { + return const _InitialSyncBody(); + } else { + return const _Body(); + } }, - child: GestureDetector( - onScaleStart: (_) { - _bloc.add(const _StartScaling()); - }, - onScaleUpdate: (details) { - _bloc.add(_SetScale(details.scale)); - }, - onScaleEnd: (_) { - _bloc.add(const _EndScaling()); - }, - child: LayoutBuilder( - builder: (context, constraints) => _BlocBuilder( - buildWhen: (previous, current) => - previous.contentListMaxExtent != - current.contentListMaxExtent || - (previous.isEnableMemoryCollection && - previous.memoryCollections.isNotEmpty) != - (current.isEnableMemoryCollection && - current.memoryCollections.isNotEmpty), - builder: (context, state) { - final scrollExtent = _getScrollViewExtent( - context: context, - constraints: constraints, - hasMemoryCollection: state.isEnableMemoryCollection && - state.memoryCollections.isNotEmpty, - contentListMaxExtent: state.contentListMaxExtent, - ); - return Stack( - children: [ - DraggableScrollbar.semicircle( - controller: _scrollController, - overrideMaxScrollExtent: scrollExtent, - // status bar + app bar - topOffset: _getAppBarExtent(context), - bottomOffset: - AppDimension.of(context).homeBottomAppBarHeight, - labelTextBuilder: (_) => const _ScrollLabel(), - labelPadding: - const EdgeInsets.symmetric(horizontal: 40), - backgroundColor: Theme.of(context).elevate( - Theme.of(context).colorScheme.inverseSurface, 3), - heightScrollThumb: 60, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith(scrollbars: false), - child: RefreshIndicator( - onRefresh: () async { - _bloc.add(const _Reload()); - await _bloc.stream.first; - }, - child: CustomScrollView( - controller: _scrollController, - physics: _finger >= 2 - ? const NeverScrollableScrollPhysics() - : null, - slivers: [ - _BlocSelector( - selector: (state) => - state.selectedItems.isEmpty, - builder: (context, isEmpty) => isEmpty - ? const _AppBar() - : const _SelectionAppBar(), - ), - _BlocBuilder( - buildWhen: (previous, current) => - (previous.isEnableMemoryCollection && - previous - .memoryCollections.isNotEmpty) != - (current.isEnableMemoryCollection && - current.memoryCollections.isNotEmpty), - builder: (context, state) { - if (state.isEnableMemoryCollection && - state.memoryCollections.isNotEmpty) { - return const _MemoryCollectionList(); - } else { - return const SliverToBoxAdapter(); - } - }, - ), - _BlocSelector( - selector: (state) => state.scale, - builder: (context, scale) => - SliverTransitionedScale( - scale: scale, - baseSliver: const _ContentList(), - overlaySliver: const _ScalingList(), - ), - ), - SliverToBoxAdapter( - child: SizedBox( - height: AppDimension.of(context) - .homeBottomAppBarHeight, - ), - ), - ], - ), - ), + ), + ), + ); + } + + late final _bloc = context.bloc; + + final _key = GlobalKey(); + bool? _isVisible; +} + +class _InitialSyncBody extends StatelessWidget { + const _InitialSyncBody(); + + @override + Widget build(BuildContext context) { + return CustomScrollView( + slivers: [ + const _AppBar(), + _BlocSelector( + selector: (state) => state.syncProgress, + builder: (context, syncProgress) { + 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, ), - ), - Align( - alignment: Alignment.bottomCenter, - child: NavigationBarBlurFilter( - height: - AppDimension.of(context).homeBottomAppBarHeight, + const SizedBox(height: 8), + LinearProgressIndicator( + value: (syncProgress?.progress ?? 0) == 0 + ? null + : syncProgress!.progress, ), - ), - ], - ); - }, + const SizedBox(height: 8), + Text( + syncProgress?.text ?? "", + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), ), - ), + ); + }, + ), + ], + ); + } +} + +class _Body extends StatefulWidget { + const _Body(); + + @override + State createState() => _BodyState(); +} + +@npLog +class _BodyState extends State<_Body> { + @override + Widget build(BuildContext context) { + return FingerListener( + onFingerChanged: (finger) { + setState(() { + _finger = finger; + }); + }, + child: GestureDetector( + onScaleStart: (_) { + _bloc.add(const _StartScaling()); + }, + onScaleUpdate: (details) { + _bloc.add(_SetScale(details.scale)); + }, + onScaleEnd: (_) { + _bloc.add(const _EndScaling()); + }, + child: LayoutBuilder( + builder: (context, constraints) => _BlocBuilder( + buildWhen: (previous, current) => + previous.contentListMaxExtent != current.contentListMaxExtent || + (previous.isEnableMemoryCollection && + previous.memoryCollections.isNotEmpty) != + (current.isEnableMemoryCollection && + current.memoryCollections.isNotEmpty), + builder: (context, state) { + final scrollExtent = _getScrollViewExtent( + context: context, + constraints: constraints, + hasMemoryCollection: state.isEnableMemoryCollection && + state.memoryCollections.isNotEmpty, + contentListMaxExtent: state.contentListMaxExtent, + ); + return Stack( + children: [ + DraggableScrollbar.semicircle( + controller: _scrollController, + overrideMaxScrollExtent: scrollExtent, + // status bar + app bar + topOffset: _getAppBarExtent(context), + bottomOffset: + AppDimension.of(context).homeBottomAppBarHeight, + labelTextBuilder: (_) => const _ScrollLabel(), + labelPadding: const EdgeInsets.symmetric(horizontal: 40), + backgroundColor: Theme.of(context).elevate( + Theme.of(context).colorScheme.inverseSurface, 3), + heightScrollThumb: 60, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(scrollbars: false), + child: RefreshIndicator( + onRefresh: () async { + _bloc.add(const _Reload()); + await _bloc.stream.first; + }, + child: CustomScrollView( + controller: _scrollController, + physics: _finger >= 2 + ? const NeverScrollableScrollPhysics() + : null, + slivers: [ + _BlocSelector( + selector: (state) => state.selectedItems.isEmpty, + builder: (context, isEmpty) => isEmpty + ? const _AppBar() + : const _SelectionAppBar(), + ), + _BlocBuilder( + buildWhen: (previous, current) => + (previous.isEnableMemoryCollection && + previous.memoryCollections.isNotEmpty) != + (current.isEnableMemoryCollection && + current.memoryCollections.isNotEmpty), + builder: (context, state) { + if (state.isEnableMemoryCollection && + state.memoryCollections.isNotEmpty) { + return const _MemoryCollectionList(); + } else { + return const SliverToBoxAdapter(); + } + }, + ), + _BlocSelector( + selector: (state) => state.scale, + builder: (context, scale) => + SliverTransitionedScale( + scale: scale, + baseSliver: const _ContentList(), + overlaySliver: const _ScalingList(), + ), + ), + SliverToBoxAdapter( + child: SizedBox( + height: AppDimension.of(context) + .homeBottomAppBarHeight, + ), + ), + ], + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: NavigationBarBlurFilter( + height: AppDimension.of(context).homeBottomAppBarHeight, + ), + ), + ], + ); + }, ), ), ), @@ -303,9 +381,7 @@ class _WrappedHomePhotosState extends State<_WrappedHomePhotos> { late final _bloc = context.bloc; - final _key = GlobalKey(); final _scrollController = ScrollController(); - bool? _isVisible; var _finger = 0; } diff --git a/app/lib/widget/home_photos2.g.dart b/app/lib/widget/home_photos2.g.dart index e562b685..f66dc80f 100644 --- a/app/lib/widget/home_photos2.g.dart +++ b/app/lib/widget/home_photos2.g.dart @@ -22,6 +22,7 @@ abstract class $_StateCopyWithWorker { bool? isEnableMemoryCollection, List? memoryCollections, double? contentListMaxExtent, + Progress? syncProgress, int? zoom, double? scale, ExceptionEvent? error}); @@ -40,6 +41,7 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { dynamic isEnableMemoryCollection, dynamic memoryCollections, dynamic contentListMaxExtent = copyWithNull, + dynamic syncProgress = copyWithNull, dynamic zoom, dynamic scale = copyWithNull, dynamic error = copyWithNull}) { @@ -57,6 +59,9 @@ class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { contentListMaxExtent: contentListMaxExtent == copyWithNull ? that.contentListMaxExtent : contentListMaxExtent as double?, + syncProgress: syncProgress == copyWithNull + ? that.syncProgress + : syncProgress as Progress?, zoom: zoom as int? ?? that.zoom, scale: scale == copyWithNull ? that.scale : scale as double?, error: error == copyWithNull ? that.error : error as ExceptionEvent?); @@ -81,6 +86,13 @@ extension _$_WrappedHomePhotosStateNpLog on _WrappedHomePhotosState { static final log = Logger("widget.home_photos2._WrappedHomePhotosState"); } +extension _$_BodyStateNpLog on _BodyState { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.home_photos2._BodyState"); +} + extension _$__NpLog on __ { // ignore: unused_element Logger get _log => log; @@ -123,7 +135,7 @@ extension _$_ContentListBodyNpLog on _ContentListBody { extension _$_StateToString on _State { String _$toString() { // ignore: unnecessary_string_interpolations - return "_State {files: [length: ${files.length}], isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: {length: ${selectedItems.length}}, visibleItems: {length: ${visibleItems.length}}, isEnableMemoryCollection: $isEnableMemoryCollection, memoryCollections: [length: ${memoryCollections.length}], contentListMaxExtent: ${contentListMaxExtent == null ? null : "${contentListMaxExtent!.toStringAsFixed(3)}"}, zoom: $zoom, scale: ${scale == null ? null : "${scale!.toStringAsFixed(3)}"}, error: $error}"; + return "_State {files: [length: ${files.length}], isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: {length: ${selectedItems.length}}, visibleItems: {length: ${visibleItems.length}}, isEnableMemoryCollection: $isEnableMemoryCollection, memoryCollections: [length: ${memoryCollections.length}], contentListMaxExtent: ${contentListMaxExtent == null ? null : "${contentListMaxExtent!.toStringAsFixed(3)}"}, syncProgress: $syncProgress, zoom: $zoom, scale: ${scale == null ? null : "${scale!.toStringAsFixed(3)}"}, error: $error}"; } } @@ -212,6 +224,13 @@ extension _$_SetContentListMaxExtentToString on _SetContentListMaxExtent { } } +extension _$_SetSyncProgressToString on _SetSyncProgress { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetSyncProgress {progress: $progress}"; + } +} + extension _$_StartScalingToString on _StartScaling { String _$toString() { // ignore: unnecessary_string_interpolations