diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4a90918..3800d0c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -67,6 +67,16 @@ android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" android:exported="false" /> + + + + + + diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 736525b..7fb4a9d 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -3,11 +3,14 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:http/http.dart' as http; +import 'package:nextcloud/nextcloud.dart'; import '../api_error.dart'; import '../marianumcloud/talk/talk_error.dart'; import 'app_exception.dart'; +import 'auth_exception.dart'; import 'network_exception.dart'; +import 'not_found_exception.dart'; import 'parse_exception.dart'; import 'server_exception.dart'; import 'talk_exception.dart'; @@ -51,6 +54,33 @@ AppException? _dioToAppException(DioException error) { } } +/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx. +/// Its toString() dumps every response header, so the details keep only the +/// status plus a trimmed body preview (same format as the Talk API errors). +AppException _dynamiteToAppException(DynamiteApiException error) { + final status = error.statusCode; + final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim(); + final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body; + final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview'; + switch (status) { + case 401: + return AuthException.unauthorized(technicalDetails: detail); + case 403: + return AuthException.forbidden(technicalDetails: detail); + case 404: + return NotFoundException(technicalDetails: detail); + case 429: + return ServerException( + statusCode: status, + userMessage: + 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.', + technicalDetails: detail, + ); + default: + return ServerException(statusCode: status, technicalDetails: detail); + } +} + String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { if (error == null) return fallback; if (error is AppException) return error.userMessage; @@ -62,6 +92,10 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { if (mapped != null) return mapped.userMessage; } + if (error is DynamiteApiException) { + return _dynamiteToAppException(error).userMessage; + } + if (error is SocketException) { return const NetworkException().userMessage; } @@ -92,6 +126,10 @@ String? errorToTechnicalDetails(Object? error) { final mapped = _dioToAppException(error); if (mapped != null) return mapped.technicalDetails ?? mapped.toString(); } + if (error is DynamiteApiException) { + final mapped = _dynamiteToAppException(error); + return mapped.technicalDetails ?? mapped.toString(); + } return error.toString(); } @@ -102,6 +140,9 @@ bool errorAllowsRetry(Object? error) { final mapped = _dioToAppException(error); if (mapped != null) return mapped.allowRetry; } + if (error is DynamiteApiException) { + return _dynamiteToAppException(error).allowRetry; + } return true; } diff --git a/lib/api/errors/server_exception.dart b/lib/api/errors/server_exception.dart index 9d5aab1..8cab2b5 100644 --- a/lib/api/errors/server_exception.dart +++ b/lib/api/errors/server_exception.dart @@ -8,9 +8,12 @@ class ServerException extends AppException { String? userMessage, super.technicalDetails, }) : super( + // Status code intentionally not in the user message — it lives in + // technicalDetails behind "Details anzeigen". userMessage: userMessage ?? - 'Der Server hat gerade Probleme (Status $statusCode). Bitte später erneut versuchen.', + 'Der Server konnte die Anfrage gerade nicht verarbeiten. ' + 'Bitte versuche es in einem Moment erneut.', allowRetry: true, ); } diff --git a/lib/api/errors/talk_exception.dart b/lib/api/errors/talk_exception.dart index 52190c2..00bbe18 100644 --- a/lib/api/errors/talk_exception.dart +++ b/lib/api/errors/talk_exception.dart @@ -26,7 +26,8 @@ class TalkException extends AppException { return 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.'; default: if (e.code >= 500) { - return 'Talk-Server hat gerade Probleme (${e.code}).'; + return 'Der Chat-Server konnte die Anfrage gerade nicht verarbeiten. ' + 'Bitte versuche es in einem Moment erneut.'; } return e.message.isNotEmpty ? e.message diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart index 8d9c87f..3376023 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:nextcloud/nextcloud.dart'; +import '../../../../retry.dart'; import '../../webdav_api.dart'; import 'cacheable_file.dart'; import 'list_files_params.dart'; @@ -10,7 +11,11 @@ import 'list_files_response.dart'; class ListFiles extends WebdavApi { ListFilesParams params; - ListFiles(this.params) : super(params); + /// Forwarded to [retryOnTransientError] so the UI can surface "Erneuter + /// Versuch (2 von 3)" while a 5xx-plagued listing self-heals. + final void Function(int nextAttempt, int maxAttempts)? onRetry; + + ListFiles(this.params, {this.onRetry}) : super(params); // The Nextcloud root listing is significantly slower than subdirectories on // our instance, so it gets a much longer ceiling. Subfolders fall back to a @@ -44,15 +49,25 @@ class ListFiles extends WebdavApi { ncmounttype: true, ); - var files = await _fetch(webdav, prop, timeout); + // Our instance intermittently answers the (slow) root PROPFIND with a + // quick empty 500 — retry those transparently before surfacing an error. + var files = await retryOnTransientError( + () => _fetch(webdav, prop, timeout), + onRetry: onRetry, + ); // A freshly-entered incoming share sometimes answers its first PROPFIND // without the OC/NC props (no fileid / has-preview / mount-type) while the // share mount warms up server-side — which drops thumbnails AND share // badges together. Retry a couple of times so the folder self-heals - // instead of needing manual re-entry. + // instead of needing manual re-entry. Best-effort: a failure here must + // not discard the listing that was already fetched successfully. for (var attempt = 0; attempt < 2 && _looksIncomplete(files); attempt++) { await Future.delayed(const Duration(milliseconds: 700)); - files = await _fetch(webdav, prop, timeout); + try { + files = await _fetch(webdav, prop, timeout); + } on Exception { + break; + } } return ListFilesResponse(files); @@ -65,7 +80,11 @@ class ListFiles extends WebdavApi { ) async { final davFiles = (await webdav - .propfind(PathUri.parse(params.path), prop: prop) + .propfind( + PathUri.parse(params.path), + prop: prop, + depth: WebDavDepth.one, + ) .timeout(timeout)) .toWebDavFiles(); final files = davFiles.map(CacheableFile.fromDavFile).toSet(); diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart index ea87b32..06bbf7c 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart @@ -17,9 +17,10 @@ class ListFilesCache extends SimpleCache { super.onError, required String path, super.renew = false, + void Function(int nextAttempt, int maxAttempts)? onRetry, }) : super( cacheTime: _cacheTimeFor(path), - loader: () => ListFiles(ListFilesParams(path)).run(), + loader: () => ListFiles(ListFilesParams(path), onRetry: onRetry).run(), fromJson: ListFilesResponse.fromJson, onUpdate: onUpdate, ) { diff --git a/lib/api/retry.dart b/lib/api/retry.dart new file mode 100644 index 0000000..068f8ab --- /dev/null +++ b/lib/api/retry.dart @@ -0,0 +1,38 @@ +import 'package:nextcloud/nextcloud.dart'; + +/// True for errors worth a short automatic retry: HTTP 5xx thrown by the +/// nextcloud/dynamite clients. Our instance intermittently answers the slow +/// root PROPFIND with a quick empty 500 via nginx that succeeds on retry. +/// Timeouts are deliberately not transient — the root listing already has a +/// multi-minute ceiling, so repeating it would multiply the wait. +bool isTransientServerError(Object error) => + error is DynamiteApiException && + error.statusCode >= 500 && + error.statusCode <= 599; + +Duration _defaultRetryDelay(int retry) => + retry == 1 ? const Duration(seconds: 1) : const Duration(seconds: 3); + +/// Runs [attempt] up to [maxAttempts] times, waiting [delayFor] between +/// tries. Errors that don't match [shouldRetry] and the final failure are +/// rethrown unchanged. [onRetry] fires before each wait with the 1-based +/// number of the upcoming attempt, e.g. `(2, 3)` — used to surface retry +/// progress in the UI. [delayFor] receives the 1-based index of the retry +/// being scheduled and is injectable so tests run instantly. +Future retryOnTransientError( + Future Function() attempt, { + int maxAttempts = 3, + Duration Function(int retry) delayFor = _defaultRetryDelay, + bool Function(Object error) shouldRetry = isTransientServerError, + void Function(int nextAttempt, int maxAttempts)? onRetry, +}) async { + for (var attemptNo = 1; ; attemptNo++) { + try { + return await attempt(); + } catch (e) { + if (attemptNo >= maxAttempts || !shouldRetry(e)) rethrow; + onRetry?.call(attemptNo + 1, maxAttempts); + await Future.delayed(delayFor(attemptNo)); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 4c80079..4ff73a1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -44,11 +44,13 @@ import 'storage/settings.dart'; import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; import 'utils/app_paths.dart'; +import 'utils/downloads/download_manager.dart'; import 'view/login/login.dart'; import 'view/login/post_login_splash.dart'; import 'widget/app_progress_indicator.dart'; import 'widget/breaker/breaker.dart'; import 'widget/debug/cache_view.dart'; +import 'widget/downloads/download_tray.dart'; import 'widget_data/widget_sync.dart'; Future main() async { @@ -98,6 +100,15 @@ Future main() async { await PushRenderer.ensureChannels(); FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage); + // Wire up the native background downloader (progress notifications + tap + // handling) before the UI so a completion notification tapped during cold + // start is captured and opened once the downloads tray mounts. + unawaited( + DownloadManager.instance.initialize().onError( + (e, _) => log('DownloadManager init failed: $e'), + ), + ); + // Wire up the home-screen widget bridge before runApp so any widget render // triggered during startup hits initialised native storage. await WidgetSync.ensureInitialized(); @@ -285,8 +296,14 @@ class _MainState extends State
{ checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, debugShowCheckedModeBanner: false, + navigatorKey: AppRoutes.rootNavigatorKey, // Used by ChatView.didPopNext to reclaim the global ChatBloc. - navigatorObservers: [AppRoutes.chatRouteObserver], + // DownloadRouteObserver tracks full-page navigations so the downloads + // chip only surfaces once the user leaves the screen they started on. + navigatorObservers: [ + AppRoutes.chatRouteObserver, + DownloadRouteObserver(), + ], localizationsDelegates: const [ ...GlobalMaterialLocalizations.delegates, GlobalWidgetsLocalizations.delegate, @@ -304,7 +321,10 @@ class _MainState extends State
{ // black flash. builder: (context, child) => ColoredBox( color: LightAppTheme.marianumRed, - child: child ?? const SizedBox.shrink(), + // Downloads tray mounted ABOVE the navigator so its chip floats over + // every route (folder views, chat, viewer are full-page pushes that + // would otherwise cover it). + child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), ), home: LoaderOverlay( child: Breaker( diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index b4c195d..32680b8 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -49,6 +49,17 @@ class AppRoutes { static final RouteObserver> chatRouteObserver = RouteObserver>(); + /// Root navigator key, set on [MaterialApp]. Lets globally-mounted UI (e.g. + /// the downloads tray, which lives above the navigator in `MaterialApp.builder` + /// and is therefore never covered by a pushed route) open full-page routes. + static final GlobalKey rootNavigatorKey = + GlobalKey(); + + /// A context that is a descendant of the root navigator (its overlay), safe to + /// pass to `pushScreen`/`showModalBottomSheet` from outside the route tree. + static BuildContext? get overlayContext => + rootNavigatorKey.currentState?.overlay?.context; + static void openFolder(BuildContext context, List path) { pushScreen(context, withNavBar: false, screen: Files(path: path)); } diff --git a/lib/state/app/infrastructure/loadable_state/loadable_state.dart b/lib/state/app/infrastructure/loadable_state/loadable_state.dart index 5b5d0c9..14cb551 100644 --- a/lib/state/app/infrastructure/loadable_state/loadable_state.dart +++ b/lib/state/app/infrastructure/loadable_state/loadable_state.dart @@ -14,6 +14,9 @@ abstract class LoadableState with _$LoadableState { required int? lastFetch, required void Function()? reFetch, required LoadingError? error, + // Transient progress note under the primary loading spinner (e.g. retry + // progress). Lives only within one fetch cycle — never persisted. + String? statusText, }) = _LoadableState; bool _hasError() => error != null; diff --git a/lib/state/app/infrastructure/loadable_state/loadable_state.freezed.dart b/lib/state/app/infrastructure/loadable_state/loadable_state.freezed.dart index 0041ee9..1f7a97f 100644 --- a/lib/state/app/infrastructure/loadable_state/loadable_state.freezed.dart +++ b/lib/state/app/infrastructure/loadable_state/loadable_state.freezed.dart @@ -14,7 +14,9 @@ T _$identity(T value) => value; /// @nodoc mixin _$LoadableState { - bool get isLoading; TState? get data; int? get lastFetch; void Function()? get reFetch; LoadingError? get error; + bool get isLoading; TState? get data; int? get lastFetch; void Function()? get reFetch; LoadingError? get error;// Transient progress note under the primary loading spinner (e.g. retry +// progress). Lives only within one fetch cycle — never persisted. + String? get statusText; /// Create a copy of LoadableState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,16 +27,16 @@ $LoadableStateCopyWith> get copyWith => _$Loadable @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is LoadableState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.lastFetch, lastFetch) || other.lastFetch == lastFetch)&&(identical(other.reFetch, reFetch) || other.reFetch == reFetch)&&(identical(other.error, error) || other.error == error)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is LoadableState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.lastFetch, lastFetch) || other.lastFetch == lastFetch)&&(identical(other.reFetch, reFetch) || other.reFetch == reFetch)&&(identical(other.error, error) || other.error == error)&&(identical(other.statusText, statusText) || other.statusText == statusText)); } @override -int get hashCode => Object.hash(runtimeType,isLoading,const DeepCollectionEquality().hash(data),lastFetch,reFetch,error); +int get hashCode => Object.hash(runtimeType,isLoading,const DeepCollectionEquality().hash(data),lastFetch,reFetch,error,statusText); @override String toString() { - return 'LoadableState<$TState>(isLoading: $isLoading, data: $data, lastFetch: $lastFetch, reFetch: $reFetch, error: $error)'; + return 'LoadableState<$TState>(isLoading: $isLoading, data: $data, lastFetch: $lastFetch, reFetch: $reFetch, error: $error, statusText: $statusText)'; } @@ -45,7 +47,7 @@ abstract mixin class $LoadableStateCopyWith { factory $LoadableStateCopyWith(LoadableState value, $Res Function(LoadableState) _then) = _$LoadableStateCopyWithImpl; @useResult $Res call({ - bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error + bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error, String? statusText }); @@ -62,14 +64,15 @@ class _$LoadableStateCopyWithImpl /// Create a copy of LoadableState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? isLoading = null,Object? data = freezed,Object? lastFetch = freezed,Object? reFetch = freezed,Object? error = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? isLoading = null,Object? data = freezed,Object? lastFetch = freezed,Object? reFetch = freezed,Object? error = freezed,Object? statusText = freezed,}) { return _then(_self.copyWith( isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable as bool,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable as TState?,lastFetch: freezed == lastFetch ? _self.lastFetch : lastFetch // ignore: cast_nullable_to_non_nullable as int?,reFetch: freezed == reFetch ? _self.reFetch : reFetch // ignore: cast_nullable_to_non_nullable as void Function()?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable -as LoadingError?, +as LoadingError?,statusText: freezed == statusText ? _self.statusText : statusText // ignore: cast_nullable_to_non_nullable +as String?, )); } /// Create a copy of LoadableState @@ -166,10 +169,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error, String? statusText)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _LoadableState() when $default != null: -return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error);case _: +return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error,_that.statusText);case _: return orElse(); } @@ -187,10 +190,10 @@ return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.e /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error, String? statusText) $default,) {final _that = this; switch (_that) { case _LoadableState(): -return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error);case _: +return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error,_that.statusText);case _: throw StateError('Unexpected subclass'); } @@ -207,10 +210,10 @@ return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.e /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error, String? statusText)? $default,) {final _that = this; switch (_that) { case _LoadableState() when $default != null: -return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error);case _: +return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error,_that.statusText);case _: return null; } @@ -222,7 +225,7 @@ return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.e class _LoadableState extends LoadableState { - const _LoadableState({required this.isLoading, required this.data, required this.lastFetch, required this.reFetch, required this.error}): super._(); + const _LoadableState({required this.isLoading, required this.data, required this.lastFetch, required this.reFetch, required this.error, this.statusText}): super._(); @override final bool isLoading; @@ -230,6 +233,9 @@ class _LoadableState extends LoadableState { @override final int? lastFetch; @override final void Function()? reFetch; @override final LoadingError? error; +// Transient progress note under the primary loading spinner (e.g. retry +// progress). Lives only within one fetch cycle — never persisted. +@override final String? statusText; /// Create a copy of LoadableState /// with the given fields replaced by the non-null parameter values. @@ -241,16 +247,16 @@ _$LoadableStateCopyWith> get copyWith => __$Loada @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadableState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.lastFetch, lastFetch) || other.lastFetch == lastFetch)&&(identical(other.reFetch, reFetch) || other.reFetch == reFetch)&&(identical(other.error, error) || other.error == error)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadableState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.lastFetch, lastFetch) || other.lastFetch == lastFetch)&&(identical(other.reFetch, reFetch) || other.reFetch == reFetch)&&(identical(other.error, error) || other.error == error)&&(identical(other.statusText, statusText) || other.statusText == statusText)); } @override -int get hashCode => Object.hash(runtimeType,isLoading,const DeepCollectionEquality().hash(data),lastFetch,reFetch,error); +int get hashCode => Object.hash(runtimeType,isLoading,const DeepCollectionEquality().hash(data),lastFetch,reFetch,error,statusText); @override String toString() { - return 'LoadableState<$TState>(isLoading: $isLoading, data: $data, lastFetch: $lastFetch, reFetch: $reFetch, error: $error)'; + return 'LoadableState<$TState>(isLoading: $isLoading, data: $data, lastFetch: $lastFetch, reFetch: $reFetch, error: $error, statusText: $statusText)'; } @@ -261,7 +267,7 @@ abstract mixin class _$LoadableStateCopyWith implements $LoadableSt factory _$LoadableStateCopyWith(_LoadableState value, $Res Function(_LoadableState) _then) = __$LoadableStateCopyWithImpl; @override @useResult $Res call({ - bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error + bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error, String? statusText }); @@ -278,14 +284,15 @@ class __$LoadableStateCopyWithImpl /// Create a copy of LoadableState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? isLoading = null,Object? data = freezed,Object? lastFetch = freezed,Object? reFetch = freezed,Object? error = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? isLoading = null,Object? data = freezed,Object? lastFetch = freezed,Object? reFetch = freezed,Object? error = freezed,Object? statusText = freezed,}) { return _then(_LoadableState( isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable as bool,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable as TState?,lastFetch: freezed == lastFetch ? _self.lastFetch : lastFetch // ignore: cast_nullable_to_non_nullable as int?,reFetch: freezed == reFetch ? _self.reFetch : reFetch // ignore: cast_nullable_to_non_nullable as void Function()?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable -as LoadingError?, +as LoadingError?,statusText: freezed == statusText ? _self.statusText : statusText // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart index 2139a70..8daa5eb 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart @@ -111,7 +111,10 @@ class LoadableStateConsumer< Expanded( child: Stack( children: [ - LoadableStatePrimaryLoading(visible: showPrimaryLoading), + LoadableStatePrimaryLoading( + visible: showPrimaryLoading, + statusText: loadableState.statusText, + ), LoadableStateBackgroundLoading( visible: showBackgroundLoading, ), diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart index 44d359b..2b70ead 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart @@ -1,17 +1,105 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../../../../widget/app_progress_indicator.dart'; import 'loadable_state_consumer.dart'; -class LoadableStatePrimaryLoading extends StatelessWidget { +/// Full-screen spinner for the initial load. A minutes-long spinner without +/// feedback looks stuck (the Nextcloud root listing can take that long), so +/// after [slowHintAfter] a generic slow-server note fades in below it. An +/// explicit [statusText] (e.g. retry progress from the bloc) always takes +/// precedence over the timer-based hint. +class LoadableStatePrimaryLoading extends StatefulWidget { final bool visible; - const LoadableStatePrimaryLoading({required this.visible, super.key}); + final String? statusText; + final Duration slowHintAfter; + + const LoadableStatePrimaryLoading({ + required this.visible, + this.statusText, + this.slowHintAfter = const Duration(seconds: 8), + super.key, + }); + + static const String slowHintText = 'Der Server antwortet verzögert …'; @override - Widget build(BuildContext context) => AnimatedOpacity( - opacity: visible ? 1.0 : 0.0, - duration: LoadableStateConsumer.animationDuration, - curve: Curves.easeInOut, - child: const Center(child: AppProgressIndicator.large()), - ); + State createState() => + _LoadableStatePrimaryLoadingState(); +} + +class _LoadableStatePrimaryLoadingState + extends State { + Timer? _slowHintTimer; + bool _showSlowHint = false; + + @override + void initState() { + super.initState(); + _restartSlowHintTimer(); + } + + @override + void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.visible != oldWidget.visible) _restartSlowHintTimer(); + } + + void _restartSlowHintTimer() { + _slowHintTimer?.cancel(); + _showSlowHint = false; + if (!widget.visible) return; + _slowHintTimer = Timer(widget.slowHintAfter, () { + if (mounted) setState(() => _showSlowHint = true); + }); + } + + @override + void dispose() { + _slowHintTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final status = + widget.statusText ?? + (_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null); + + return AnimatedOpacity( + opacity: widget.visible ? 1.0 : 0.0, + duration: LoadableStateConsumer.animationDuration, + curve: Curves.easeInOut, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppProgressIndicator.large(), + AnimatedSwitcher( + duration: LoadableStateConsumer.animationDuration, + child: status == null + ? const SizedBox.shrink() + : Padding( + key: ValueKey(status), + padding: const EdgeInsets.only( + top: 16, + left: 24, + right: 24, + ), + child: Text( + status, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).hintColor, + ), + ), + ), + ), + ], + ), + ), + ); + } } diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index 8f23f7c..8d5509c 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -35,10 +35,24 @@ abstract class LoadableHydratedBloc< lastFetch: state.lastFetch, reFetch: retry, error: state.error, + statusText: state.statusText, ), ); }); + on>( + (event, emit) => emit( + LoadableState( + isLoading: state.isLoading, + data: innerState, + lastFetch: state.lastFetch, + reFetch: state.reFetch, + error: state.error, + statusText: event.statusText, + ), + ), + ); + on>( (event, emit) => emit( LoadableState( diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart index 8f3150e..7ec3e75 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart @@ -19,4 +19,12 @@ class Error extends LoadableHydratedBlocEvent { class RefetchStarted extends LoadableHydratedBlocEvent {} +/// Sets the state's `statusText` — a transient progress note under the +/// primary loading spinner (e.g. "Erneuter Versuch (2 von 3) …"). Cleared +/// automatically by [DataGathered], [Error], [RefetchStarted] and [Reset]. +class LoadingStatus extends LoadableHydratedBlocEvent { + final String? statusText; + LoadingStatus(this.statusText); +} + class Reset extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index a0dbb1d..5b318e8 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -96,6 +96,10 @@ class FilesBloc ); add(Emit((s) => s.copyWith(listing: cached))); }, + onRetry: (next, max) { + if (isStale()) return; + add(LoadingStatus('Erneuter Versuch ($next von $max) …')); + }, onError: (e) => capturedError = e, ); } catch (e) { diff --git a/lib/state/app/modules/files/data_provider/files_data_provider.dart b/lib/state/app/modules/files/data_provider/files_data_provider.dart index 3d76400..079e2b9 100644 --- a/lib/state/app/modules/files/data_provider/files_data_provider.dart +++ b/lib/state/app/modules/files/data_provider/files_data_provider.dart @@ -21,6 +21,7 @@ class FilesDataProvider { void Function(ListFilesResponse)? onCacheData, void Function(Object)? onError, bool renew = false, + void Function(int nextAttempt, int maxAttempts)? onRetry, }) => resolveFromCache( (onUpdate, onError) => ListFilesCache( path: path, @@ -28,6 +29,7 @@ class FilesDataProvider { onCacheData: onCacheData, onError: onError, renew: renew, + onRetry: onRetry, ), onError: onError, operationName: 'listFiles', diff --git a/lib/utils/download_manager.dart b/lib/utils/download_manager.dart deleted file mode 100644 index 8e68ab4..0000000 --- a/lib/utils/download_manager.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../api/marianumcloud/webdav/webdav_api.dart'; -import '../model/account_data.dart'; -import 'file_downloader.dart'; - -/// Snapshot of a single download's lifecycle. UI widgets rebuild whenever the -/// owning [DownloadJob.status] notifier emits a new instance. -sealed class DownloadStatus { - const DownloadStatus(); -} - -class DownloadInProgress extends DownloadStatus { - const DownloadInProgress(this.percent); - final double percent; -} - -class DownloadDone extends DownloadStatus { - const DownloadDone(this.localPath); - final String localPath; -} - -class DownloadCancelled extends DownloadStatus { - const DownloadCancelled(); -} - -class DownloadFailed extends DownloadStatus { - const DownloadFailed(this.message); - final String message; -} - -/// Tracks a single in-flight or finished download. Survives widget dispose so -/// that re-entering the screen reattaches to the same job. -class DownloadJob { - DownloadJob({ - required this.remotePath, - required this.name, - required this.localPath, - required FileDownloader downloader, - }) : _downloader = downloader; - - final String remotePath; - final String name; - final String localPath; - final FileDownloader _downloader; - - final ValueNotifier status = ValueNotifier( - const DownloadInProgress(0), - ); - bool _disposed = false; - - bool get isFinished => - status.value is DownloadDone || - status.value is DownloadFailed || - status.value is DownloadCancelled; - - void cancel() { - if (isFinished) return; - _downloader.cancel(); - status.value = const DownloadCancelled(); - } - - void _dispose() { - if (_disposed) return; - _disposed = true; - status.dispose(); - } -} - -/// Central in-memory registry for downloads. Keyed by remote path so a file -/// triggered from multiple screens (Files + Chat) reuses one job. -/// -/// Not persistent across app restarts — restarting abandons in-flight -/// downloads and partial files are cleaned on next start attempt. -class DownloadManager { - DownloadManager._(); - static final DownloadManager instance = DownloadManager._(); - - final Map _jobs = {}; - - /// Active or recently finished job for [remotePath], or null if none. - DownloadJob? jobFor(String remotePath) => _jobs[remotePath]; - - /// Returns the existing job if a download is in progress for [remotePath], - /// otherwise starts a new one. Caller listens on [DownloadJob.status]. - Future start({ - required String remotePath, - required String name, - }) async { - final existing = _jobs[remotePath]; - if (existing != null && !existing.isFinished) return existing; - if (existing != null) { - _jobs.remove(remotePath); - scheduleMicrotask(existing._dispose); - } - - final tempDir = await getTemporaryDirectory(); - final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/'); - final localPath = '${tempDir.path}${Platform.pathSeparator}$name'; - - final downloader = FileDownloader(); - final job = DownloadJob( - remotePath: remotePath, - name: name, - localPath: localPath, - downloader: downloader, - ); - _jobs[remotePath] = job; - - downloader.run( - client: Dio(BaseOptions(headers: AccountData().authHeaders())), - url: '${WebdavApi.buildWebdavUrl()}$encodedPath', - savePath: localPath, - onProgress: (percent) { - if (job.isFinished) return; - job.status.value = DownloadInProgress(percent); - }, - onDone: () { - if (job.isFinished) return; - job.status.value = DownloadDone(localPath); - }, - onError: (error) { - if (job.isFinished) return; - try { - File(localPath).deleteSync(); - } on FileSystemException { - // partial file may not exist — ignore - } - job.status.value = DownloadFailed(error.toString()); - }, - ); - - return job; - } - - /// Removes a finished job from the registry. Safe to call from a status - /// listener: actual disposal of the underlying notifier is deferred to the - /// next microtask so the in-flight `notifyListeners` cycle can finish before - /// the notifier is destroyed. Active (unfinished) jobs are left untouched. - void clear(String remotePath) { - final job = _jobs[remotePath]; - if (job == null || !job.isFinished) return; - _jobs.remove(remotePath); - scheduleMicrotask(job._dispose); - } -} diff --git a/lib/utils/downloads/download_job.dart b/lib/utils/downloads/download_job.dart new file mode 100644 index 0000000..93423fa --- /dev/null +++ b/lib/utils/downloads/download_job.dart @@ -0,0 +1,78 @@ +import 'package:flutter/foundation.dart'; + +import '../../share_intent/remote_file_ref.dart'; + +/// Snapshot of a single download's lifecycle. UI widgets rebuild whenever the +/// owning [DownloadJob.status] notifier emits a new instance. +sealed class DownloadStatus { + const DownloadStatus(); +} + +class DownloadInProgress extends DownloadStatus { + const DownloadInProgress(this.percent); + + /// 0–100, or a value `<= 0` while the total size is still unknown + /// (indeterminate progress). + final double percent; +} + +class DownloadDone extends DownloadStatus { + const DownloadDone(this.localPath); + final String localPath; +} + +class DownloadCancelled extends DownloadStatus { + const DownloadCancelled(); +} + +class DownloadFailed extends DownloadStatus { + const DownloadFailed(this.message); + final String message; +} + +/// Tracks a single in-flight or finished download. Survives widget dispose so +/// that re-entering a screen (or opening the downloads overview) reattaches to +/// the same job. Kept engine-agnostic on purpose — the native +/// `background_downloader` task lives entirely inside [DownloadManager]. +class DownloadJob { + DownloadJob({ + required this.remotePath, + required this.name, + this.remoteFile, + }); + + final String remotePath; + final String name; + + /// Server-side reference so the viewer can offer "An Chat senden" / + /// "In Cloud speichern" for downloads triggered from Files or Talk. + final RemoteFileRef? remoteFile; + + /// Native task id, set by [DownloadManager] once the task is enqueued. + String? taskId; + + /// True once the finished file has been opened (auto-open or from the + /// overview). Opened jobs drop out of the downloads tray. + bool opened = false; + + final ValueNotifier status = ValueNotifier( + const DownloadInProgress(0), + ); + + bool get isFinished => + status.value is DownloadDone || + status.value is DownloadFailed || + status.value is DownloadCancelled; + + bool get isDone => status.value is DownloadDone; + bool get isFailed => status.value is DownloadFailed; + bool get isCancelled => status.value is DownloadCancelled; + + /// Local file path once finished, else null. + String? get localPath { + final value = status.value; + return value is DownloadDone ? value.localPath : null; + } + + void dispose() => status.dispose(); +} diff --git a/lib/utils/downloads/download_manager.dart b/lib/utils/downloads/download_manager.dart new file mode 100644 index 0000000..fa4b229 --- /dev/null +++ b/lib/utils/downloads/download_manager.dart @@ -0,0 +1,372 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart' as bd; +import 'package:flutter/foundation.dart'; + +import '../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../model/account_data.dart'; +import '../../notification/notification_service.dart'; +import '../../share_intent/remote_file_ref.dart'; +import 'download_job.dart'; + +/// Central registry for file downloads, shared between the Files list and Talk +/// chat bubbles. Backed by `background_downloader` so downloads run in a native +/// foreground service (Android) / URLSession background session (iOS) and +/// therefore keep running — with a system progress notification — while the app +/// is backgrounded or the user works elsewhere, even for large files. +/// +/// Jobs are keyed by remote (WebDAV) path so a file triggered from multiple +/// screens reuses one job. The in-memory registry is not rebuilt across a full +/// app restart; a still-running native download keeps going and its completion +/// notification stays tappable (rebuilt on demand in [_onNotificationTap]). +class DownloadManager { + DownloadManager._(); + static final DownloadManager instance = DownloadManager._(); + + /// Callback group for our tasks — scopes the notification-tap callback so it + /// only fires for downloads we started. + static const _group = 'mm_downloads'; + + /// Files land in the app's private temp area under this subdirectory, mirror- + /// ing the previous behaviour. Export to the device happens via the viewer's + /// "Teilen"/"Speichern" actions. + static const _directory = 'downloads'; + + final Map _jobs = {}; // keyed by remotePath + final Map _byTaskId = {}; + final Map _taskById = {}; + + /// All jobs the user should currently see in the downloads tray/overview: + /// everything that is in progress or finished-but-not-yet-opened (failed + /// downloads linger so the user can retry; cancelled ones drop out). + final ValueNotifier> visibleJobs = ValueNotifier(const []); + + /// Emits whenever a job reaches [DownloadDone]. The downloads tray listens to + /// decide between auto-opening (single download) and parking it in the + /// overview (parallel downloads). + final StreamController _completions = + StreamController.broadcast(); + Stream get completions => _completions.stream; + + /// Set when the user taps a completion notification; consumed by the + /// downloads tray to open the file (also covers cold-start taps). + final ValueNotifier pendingOpen = ValueNotifier(null); + + bool _initialized = false; + StreamSubscription? _updatesSub; + + /// Wires up the native downloader once. Safe to call repeatedly. + Future initialize() async { + if (_initialized) return; + _initialized = true; + + _updatesSub = bd.FileDownloader().updates.listen(_onUpdate); + bd.FileDownloader().registerCallbacks( + group: _group, + taskNotificationTapCallback: _onNotificationTap, + ); + // Run larger downloads in a native foreground service: this lifts the + // default 9-minute WorkManager timeout and greatly improves the odds of a + // big file finishing while the app sits in the background. Smaller files + // stay on the lighter WorkManager path. Requires the running notification + // configured below + the manifest FOREGROUND_SERVICE(_DATA_SYNC) entries. + await bd.FileDownloader().configure( + androidConfig: (bd.Config.runInForegroundIfFileLargerThan, 10), + ); + + bd.FileDownloader().configureNotification( + running: const bd.TaskNotification( + '{filename}', + 'Wird heruntergeladen … {progress}', + ), + complete: const bd.TaskNotification( + '{filename}', + 'Fertig – tippen zum Öffnen', + ), + error: const bd.TaskNotification('{filename}', 'Download fehlgeschlagen'), + progressBar: true, + // We route taps into the in-app viewer ourselves. + tapOpensFile: false, + ); + + // Notification permission is normally already granted via the FCM flow at + // login; request best-effort so downloads on a fresh install still notify. + try { + await bd.FileDownloader().permissions.request( + bd.PermissionType.notifications, + ); + } on Object catch (e) { + debugPrint('DownloadManager: notification permission request failed: $e'); + } + } + + /// Active or recently finished job for [remotePath], or null if none. + DownloadJob? jobFor(String remotePath) => _jobs[remotePath]; + + /// Returns the existing in-flight job for [remotePath], otherwise enqueues a + /// new one. Caller listens on [DownloadJob.status] for inline UI. + Future start({ + required String remotePath, + required String name, + RemoteFileRef? remoteFile, + }) async { + await initialize(); + + final existing = _jobs[remotePath]; + if (existing != null && !existing.isFinished) return existing; + if (existing != null) _remove(existing); + + final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/'); + final task = bd.DownloadTask( + url: '${WebdavApi.buildWebdavUrl()}$encodedPath', + headers: AccountData().authHeaders(), + filename: name, + baseDirectory: bd.BaseDirectory.temporary, + directory: _directory, + group: _group, + updates: bd.Updates.statusAndProgress, + allowPause: true, + metaData: _encodeMeta(remotePath, name, remoteFile), + ); + + final job = DownloadJob( + remotePath: remotePath, + name: name, + remoteFile: remoteFile, + )..taskId = task.taskId; + _jobs[remotePath] = job; + _byTaskId[task.taskId] = job; + _taskById[task.taskId] = task; + _refreshVisible(); + + final ok = await bd.FileDownloader().enqueue(task); + if (!ok && !job.isFinished) { + _fail(job, 'Download konnte nicht gestartet werden.'); + } + return job; + } + + /// Cancels an in-flight download. The native task is cancelled and the job + /// transitions to [DownloadCancelled] (removed from the tray). + Future cancel(DownloadJob job) async { + final taskId = job.taskId; + if (taskId != null) { + await bd.FileDownloader().cancelTaskWithId(taskId); + } + if (!job.isFinished) job.status.value = const DownloadCancelled(); + _remove(job); + } + + /// Re-enqueues a failed job under the same remote path. + Future retry(DownloadJob job) { + _remove(job); + return start( + remotePath: job.remotePath, + name: job.name, + remoteFile: job.remoteFile, + ); + } + + /// Marks a finished job as opened and drops it from the tray. Also dismisses + /// the lingering "download complete" system notification for that file. + void markOpened(DownloadJob job) { + job.opened = true; + _dismissNotification(job); + _remove(job); + } + + /// Cancels the task's completion notification. background_downloader derives + /// its Android notification id from the Java `String.hashCode()` of the task + /// id, which we reproduce to target the exact notification. Android-only — + /// iOS uses a different identifier scheme. + void _dismissNotification(DownloadJob job) { + final taskId = job.taskId; + if (taskId == null || !Platform.isAndroid) return; + final id = _androidNotificationId(taskId); + unawaited( + NotificationService().flutterLocalNotificationsPlugin + .cancel(id: id) + .catchError((Object _) {}), + ); + } + + /// Java/Kotlin `String.hashCode()` (32-bit signed) — matches the id + /// background_downloader posts its notification under. + int _androidNotificationId(String taskId) { + var hash = 0; + for (final unit in taskId.codeUnits) { + hash = (31 * hash + unit) & 0xFFFFFFFF; + } + if (hash >= 0x80000000) hash -= 0x100000000; + return hash; + } + + /// Removes a finished job from the tray without opening (swipe-to-dismiss). + void dismiss(DownloadJob job) => _remove(job); + + /// Clears the whole tray at once (swipe the chip away): cancels anything still + /// running and drops every job. The registry empties synchronously so the UI + /// updates immediately; native cancellations fire in the background. + void clearAll() { + for (final job in _jobs.values.toList()) { + final taskId = job.taskId; + if (!job.isFinished) { + if (taskId != null) { + unawaited(bd.FileDownloader().cancelTaskWithId(taskId)); + } + // Notify inline listeners (list/bubble) so their progress bars clear + // instead of freezing at the last percent. + job.status.value = const DownloadCancelled(); + } + _jobs.remove(job.remotePath); + if (taskId != null) { + _byTaskId.remove(taskId); + _taskById.remove(taskId); + } + scheduleMicrotask(job.dispose); + } + _refreshVisible(); + } + + // --- native update handling ------------------------------------------------ + + void _onUpdate(bd.TaskUpdate update) { + final job = _byTaskId[update.task.taskId]; + if (job == null || job.isFinished) return; + + switch (update) { + case bd.TaskStatusUpdate(): + switch (update.status) { + case bd.TaskStatus.complete: + unawaited(_complete(job, update.task)); + case bd.TaskStatus.failed: + case bd.TaskStatus.notFound: + _fail( + job, + update.exception?.description ?? + (update.status == bd.TaskStatus.notFound + ? 'Datei auf dem Server nicht gefunden.' + : 'Download fehlgeschlagen.'), + ); + case bd.TaskStatus.canceled: + job.status.value = const DownloadCancelled(); + _remove(job); + case bd.TaskStatus.enqueued: + case bd.TaskStatus.running: + case bd.TaskStatus.waitingToRetry: + case bd.TaskStatus.paused: + break; // progress is delivered separately + } + case bd.TaskProgressUpdate(): + // Negative values are sentinels (failed/canceled/…) handled via status. + final progress = update.progress; + job.status.value = DownloadInProgress( + progress >= 0 && progress <= 1 ? progress * 100 : 0, + ); + } + } + + Future _complete(DownloadJob job, bd.Task task) async { + if (job.isFinished) return; + final path = await task.filePath(); + if (job.isFinished) return; + job.status.value = DownloadDone(path); + _refreshVisible(); + _completions.add(job); + } + + void _fail(DownloadJob job, String message) { + if (job.isFinished) return; + job.status.value = DownloadFailed(message); + _refreshVisible(); + } + + Future _onNotificationTap( + bd.Task task, + bd.NotificationType notificationType, + ) async { + if (notificationType != bd.NotificationType.complete) return; + final job = _byTaskId[task.taskId] ?? await _rebuildJob(task); + if (job == null) return; + pendingOpen.value = job; + } + + /// Rebuilds a finished [DownloadJob] straight from a native task — used when a + /// completion notification is tapped after the app (and thus the in-memory + /// registry) was gone. + Future _rebuildJob(bd.Task task) async { + try { + final meta = _decodeMeta(task.metaData); + final path = await task.filePath(); + final job = DownloadJob( + remotePath: meta?.remotePath ?? '', + name: task.filename, + remoteFile: meta?.remoteFile, + )..taskId = task.taskId; + job.status.value = DownloadDone(path); + return job; + } on Object catch (e) { + debugPrint('DownloadManager: could not rebuild tapped task: $e'); + return null; + } + } + + // --- bookkeeping ----------------------------------------------------------- + + void _remove(DownloadJob job) { + _jobs.remove(job.remotePath); + final taskId = job.taskId; + if (taskId != null) { + _byTaskId.remove(taskId); + _taskById.remove(taskId); + } + _refreshVisible(); + scheduleMicrotask(job.dispose); + } + + void _refreshVisible() { + visibleJobs.value = _jobs.values + .where((j) => !j.opened && !j.isCancelled) + .toList(growable: false); + } + + // --- metadata (remote ref) serialisation ---------------------------------- + + String _encodeMeta(String remotePath, String name, RemoteFileRef? ref) => + jsonEncode({ + 'remotePath': remotePath, + 'name': name, + if (ref != null) + 'ref': { + 'path': ref.path, + 'name': ref.name, + 'fileId': ref.fileId, + 'hasPreview': ref.hasPreview, + }, + }); + + ({String remotePath, RemoteFileRef? remoteFile})? _decodeMeta(String meta) { + if (meta.isEmpty) return null; + final map = jsonDecode(meta) as Map; + final ref = map['ref'] as Map?; + return ( + remotePath: (map['remotePath'] as String?) ?? '', + remoteFile: ref == null + ? null + : RemoteFileRef( + path: ref['path'] as String, + name: ref['name'] as String, + fileId: ref['fileId'] as int?, + hasPreview: ref['hasPreview'] as bool?, + ), + ); + } + + @visibleForTesting + void debugDispose() { + _updatesSub?.cancel(); + _initialized = false; + } +} diff --git a/lib/utils/file_downloader.dart b/lib/utils/file_downloader.dart deleted file mode 100644 index 34c1088..0000000 --- a/lib/utils/file_downloader.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:dio/dio.dart'; - -/// Lightweight cancel handle around a single `Dio.download` call. The download -/// itself is started by [run]; the handle returns synchronously so callers can -/// install it into shared state before the first progress event can fire. -class FileDownloader { - FileDownloader(); - - final CancelToken _cancelToken = CancelToken(); - bool _cancelled = false; - - bool get isCancelled => _cancelled; - - void cancel() { - if (_cancelled) return; - _cancelled = true; - _cancelToken.cancel('user cancelled'); - } - - /// Kicks off the download. Returns immediately; the download progresses in - /// the background and events are delivered via callbacks. Callbacks are not - /// invoked once [cancel] has been called. - void run({ - required Dio client, - required String url, - required String savePath, - required void Function(double percent) onProgress, - required void Function() onDone, - required void Function(Object error) onError, - }) { - client - .download( - url, - savePath, - cancelToken: _cancelToken, - onReceiveProgress: (received, total) { - if (_cancelled || total <= 0) return; - onProgress((received / total) * 100); - }, - ) - .then((_) { - if (_cancelled) return; - onDone(); - }) - .catchError((Object error) { - if (_cancelled) return; - onError(error); - }) - .ignore(); - } -} diff --git a/lib/view/pages/files/data/file_type_icon.dart b/lib/view/pages/files/data/file_type_icon.dart index ddb3b80..792fe0c 100644 --- a/lib/view/pages/files/data/file_type_icon.dart +++ b/lib/view/pages/files/data/file_type_icon.dart @@ -19,6 +19,17 @@ IconData iconForFile(CacheableFile file) { return Icons.insert_drive_file_outlined; } +/// Icon for a bare file name (no [CacheableFile] / MIME available), e.g. in the +/// downloads overview. Falls back to a generic document icon. +IconData iconForFileName(String name) { + final ext = _extensionOf(name); + if (ext != null) { + final byExt = _extensionIcons[ext]; + if (byExt != null) return byExt; + } + return Icons.insert_drive_file_outlined; +} + String? _extensionOf(String name) { final dot = name.lastIndexOf('.'); if (dot <= 0 || dot == name.length - 1) return null; diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index 4758388..9b21a74 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -10,12 +10,13 @@ import '../../../../model/endpoint_data.dart'; import '../../../../routing/app_routes.dart'; import '../../../../share_intent/remote_file_ref.dart'; import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; -import '../../../../utils/download_manager.dart'; +import '../../../../utils/downloads/download_job.dart'; import '../../../../utils/file_clipboard.dart'; import '../../../../utils/haptics.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/details_bottom_sheet.dart'; +import '../../../../widget/downloads/download_trigger.dart'; import '../../../../widget/info_dialog.dart'; import '../../talk/widgets/highlighted_linkify.dart'; import '../sharing/share_sheet.dart'; @@ -43,101 +44,32 @@ class FileElement extends StatefulWidget { State createState() => _FileElementState(); } -class _FileElementState extends State { - DownloadJob? _job; +class _FileElementState extends State + with DownloadTrigger { + @override + String? get downloadRemotePath => + widget.file.isDirectory ? null : widget.file.path; @override void initState() { super.initState(); - _attachJob(DownloadManager.instance.jobFor(widget.file.path)); + initDownloadTrigger(); } @override void didUpdateWidget(covariant FileElement oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.file.path != widget.file.path) { - _detachJob(); - _attachJob(DownloadManager.instance.jobFor(widget.file.path)); - } + if (oldWidget.file.path != widget.file.path) refreshDownloadTrigger(); } @override void dispose() { - _detachJob(); + disposeDownloadTrigger(); super.dispose(); } - void _attachJob(DownloadJob? job) { - _job = job; - if (job == null) return; - job.status.addListener(_onStatusChange); - if (job.isFinished) { - WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange()); - } - } - - void _detachJob() { - _job?.status.removeListener(_onStatusChange); - _job = null; - } - - void _onStatusChange() { - if (!mounted) return; - final job = _job; - if (job == null) return; - final status = job.status.value; - if (status is DownloadDone) { - Haptics.success(); - DownloadManager.instance.clear(widget.file.path); - _detachJob(); - AppRoutes.openFileViewer( - context, - status.localPath, - remoteFile: RemoteFileRef.fromCacheable(widget.file), - ); - setState(() {}); - } else if (status is DownloadFailed) { - final message = status.message; - DownloadManager.instance.clear(widget.file.path); - _detachJob(); - setState(() {}); - InfoDialog.show(context, message, title: 'Download', copyable: true); - } else if (status is DownloadCancelled) { - DownloadManager.instance.clear(widget.file.path); - _detachJob(); - setState(() {}); - } else { - setState(() {}); - } - } - - Future _startDownload() async { - final job = await DownloadManager.instance.start( - remotePath: widget.file.path, - name: widget.file.name, - ); - if (!mounted) return; - if (_job == job) return; - _detachJob(); - _attachJob(job); - setState(() {}); - } - - void _confirmCancel() { - showDialog( - context: context, - builder: (dialogContext) => ConfirmDialog( - title: 'Download abbrechen?', - content: 'Möchtest du den Download abbrechen?', - cancelButton: 'Nein', - confirmButton: 'Ja, Abbrechen', - onConfirm: () => _job?.cancel(), - ), - ); - } - Widget? _subtitle() { - final status = _job?.status.value; + final status = downloadJob?.status.value; if (status is DownloadInProgress) { return Row( children: [ @@ -180,12 +112,14 @@ class _FileElementState extends State { ); return; } - final status = _job?.status.value; - if (status is DownloadInProgress) { - _confirmCancel(); + if (isDownloading) { + confirmCancelDownload(); return; } - _startDownload(); + startDownload( + name: widget.file.name, + remoteFile: RemoteFileRef.fromCacheable(widget.file), + ); } // All paths here are relative to the WebDAV root (matching CacheableFile.path). diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index b630f1f..c7712fe 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -5,13 +5,11 @@ import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../extensions/date_time.dart'; import '../../../../extensions/text.dart'; -import '../../../../routing/app_routes.dart'; import '../../../../share_intent/remote_file_ref.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; -import '../../../../utils/download_manager.dart'; +import '../../../../utils/downloads/download_job.dart'; import '../../../../utils/haptics.dart'; -import '../../../../widget/confirm_dialog.dart'; -import '../../../../widget/info_dialog.dart'; +import '../../../../widget/downloads/download_trigger.dart'; import '../data/chat_bubble_styles.dart'; import '../data/chat_message.dart'; import 'answer_reference.dart'; @@ -58,97 +56,40 @@ class ChatBubble extends StatefulWidget { } class _ChatBubbleState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, DownloadTrigger { late ChatMessage message; - DownloadJob? _job; Offset _position = Offset.zero; Offset _dragStartPosition = Offset.zero; bool _swipeActionArmed = false; + @override + String? get downloadRemotePath => + widget.bubbleData.messageParameters?['file']?.path; + @override void initState() { super.initState(); - final filePath = widget.bubbleData.messageParameters?['file']?.path; - if (filePath != null) _attachJob(DownloadManager.instance.jobFor(filePath)); + initDownloadTrigger(); + } + + @override + void didUpdateWidget(covariant ChatBubble oldWidget) { + super.didUpdateWidget(oldWidget); + final oldPath = oldWidget.bubbleData.messageParameters?['file']?.path; + if (oldPath != downloadRemotePath) refreshDownloadTrigger(); } @override void dispose() { - _detachJob(); + disposeDownloadTrigger(); super.dispose(); } - void _attachJob(DownloadJob? job) { - _job = job; - if (job == null) return; - job.status.addListener(_onStatusChange); - if (job.isFinished) { - WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange()); - } - } - - void _detachJob() { - _job?.status.removeListener(_onStatusChange); - _job = null; - } - - void _onStatusChange() { - if (!mounted) return; - final job = _job; - if (job == null) return; - final status = job.status.value; - if (status is DownloadDone) { - Haptics.success(); - DownloadManager.instance.clear(job.remotePath); - _detachJob(); - final talkFile = message.file; - AppRoutes.openFileViewer( - context, - status.localPath, - remoteFile: talkFile != null - ? RemoteFileRef.fromTalk(talkFile) - : null, - ); - setState(() {}); - } else if (status is DownloadFailed) { - final message = status.message; - DownloadManager.instance.clear(job.remotePath); - _detachJob(); - setState(() {}); - InfoDialog.show(context, message, title: 'Download fehlgeschlagen'); - } else if (status is DownloadCancelled) { - DownloadManager.instance.clear(job.remotePath); - _detachJob(); - setState(() {}); - } else { - setState(() {}); - } - } - - Future _startFileDownload() async { + void _startFileDownload() { final file = message.file; - final filePath = file?.path; - if (file == null || filePath == null) return; - final job = await DownloadManager.instance.start( - remotePath: filePath, - name: file.name, - ); - if (!mounted) return; - if (_job == job) return; - _detachJob(); - _attachJob(job); - setState(() {}); - } - - void _confirmCancel() { - ConfirmDialog( - title: 'Download abbrechen?', - content: 'Möchtest du den Download abbrechen?', - confirmButton: 'Ja, Abbrechen', - cancelButton: 'Nein', - onConfirm: () => _job?.cancel(), - ).asDialog(context); + if (file == null) return; + startDownload(name: file.name, remoteFile: RemoteFileRef.fromTalk(file)); } bool get _rendersAsCommentBubble => @@ -236,8 +177,8 @@ class _ChatBubbleState extends State return; } if (message.file == null) return; - if (_job?.status.value is DownloadInProgress) { - _confirmCancel(); + if (isDownloading) { + confirmCancelDownload(); } else { _startFileDownload(); } @@ -349,7 +290,7 @@ class _ChatBubbleState extends State timeIconColor: widget.timeIconColor, showActorDisplayName: showActorDisplayName, showBubbleTime: showBubbleTime, - downloadJob: _job, + downloadJob: downloadJob, ), ), ), diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 6fd3f3a..16ef6fc 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -13,6 +13,7 @@ import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/details_bottom_sheet.dart'; +import '../../../../widget/downloads/download_tray.dart'; import '../../../../widget/emoji_picker_dialog.dart'; import '../../../../widget/file_pick.dart'; import '../../../../widget/focus_behaviour.dart'; @@ -34,8 +35,17 @@ class _ChatTextfieldState extends State { final TextEditingController _textBoxController = TextEditingController(); final AsyncActionController _sendController = AsyncActionController(); final FocusNode _focusNode = FocusNode(); + final GlobalKey _sizeKey = GlobalKey(); String? _sendError; + /// Publishes the input bar's measured height so the downloads chip floats + /// above it instead of covering it. + void _publishHeight() { + if (!mounted) return; + final height = _sizeKey.currentContext?.size?.height; + if (height != null) downloadChipBottomObstruction.value = height; + } + void share(List uploadedRemotePaths) { shareFilesToChat( token: widget.sendToToken, @@ -104,6 +114,7 @@ class _ChatTextfieldState extends State { @override void dispose() { + downloadChipBottomObstruction.value = 0; _sendController.dispose(); _focusNode.dispose(); super.dispose(); @@ -236,7 +247,9 @@ class _ChatTextfieldState extends State { } } + WidgetsBinding.instance.addPostFrameCallback((_) => _publishHeight()); return Stack( + key: _sizeKey, children: [ Align( alignment: Alignment.bottomLeft, diff --git a/lib/widget/details_bottom_sheet.dart b/lib/widget/details_bottom_sheet.dart index fa13352..823d6e5 100644 --- a/lib/widget/details_bottom_sheet.dart +++ b/lib/widget/details_bottom_sheet.dart @@ -4,12 +4,12 @@ import 'package:flutter/material.dart'; /// custom event, etc.). All detail sheets in the app share this layout: drag /// handle on top, default theme background, optional ListTile-style header /// followed by a divider, scrollable body below. -void showDetailsBottomSheet( +Future showDetailsBottomSheet( BuildContext context, { Widget? header, required List Function(BuildContext sheetContext) children, }) { - showModalBottomSheet( + return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, diff --git a/lib/widget/downloads/download_tray.dart b/lib/widget/downloads/download_tray.dart new file mode 100644 index 0000000..a3a33c0 --- /dev/null +++ b/lib/widget/downloads/download_tray.dart @@ -0,0 +1,348 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../routing/app_routes.dart'; +import '../../utils/downloads/download_job.dart'; +import '../../utils/downloads/download_manager.dart'; +import '../../utils/haptics.dart'; +import 'downloads_sheet.dart'; + +/// Decides whether a just-finished download should open straight in the viewer. +/// +/// Auto-open only the lone, foreground case; any parallelism (past or present) +/// or a backgrounded app parks the download in the tray instead so it never +/// steals focus. Pure so it can be unit-tested. +bool shouldAutoOpenCompletion({ + required bool foreground, + required bool suppressAutoOpen, + required bool completedIsSoleVisibleJob, + required bool onOriginScreen, +}) => + foreground && + !suppressAutoOpen && + completedIsSoleVisibleJob && + onOriginScreen; + +/// Whether the downloads chip should be visible. Hidden while the overview sheet +/// is open (so it can't stack sheets) and for a single in-progress download the +/// user is still watching inline on its originating screen. Shown for parallel +/// downloads, or a lone one that is finished/failed or whose screen was left. +/// Pure so it can be unit-tested. +bool shouldShowDownloadChip({ + required bool sheetOpen, + required int jobCount, + required bool anySurfaced, +}) { + if (sheetOpen || jobCount == 0) return false; + if (jobCount >= 2) return true; + return anySurfaced; +} + +/// Height of a screen-specific bottom bar the chip must float above (e.g. the +/// chat message input). Screens publish their bar height here while active and +/// reset it to 0 on dispose; 0 means "nothing extra to clear". +final ValueNotifier downloadChipBottomObstruction = ValueNotifier(0); + +/// Bumps [epoch] on every full-page navigation (ignoring dialogs/sheets/popups) +/// so the downloads tray can tell whether the user has left the screen a +/// download was started on. +class DownloadRouteObserver extends NavigatorObserver { + static final ValueNotifier epoch = ValueNotifier(0); + + static void _bump(Route? route) { + if (route is PageRoute) epoch.value++; + } + + @override + void didPush(Route route, Route? previousRoute) => + _bump(route); + + @override + void didPop(Route route, Route? previousRoute) => + _bump(route); + + @override + void didReplace({Route? newRoute, Route? oldRoute}) => + _bump(newRoute); + + @override + void didRemove(Route route, Route? previousRoute) => + _bump(route); +} + +/// Wraps the app's navigator and floats the downloads tray above every route. +/// +/// Mounted in `MaterialApp.builder`, ABOVE the navigator, so the chip is never +/// covered by a full-page push (folder views, chat, viewer). Navigation uses +/// [AppRoutes.overlayContext] (a descendant of the root navigator) rather than +/// this widget's own context, which sits above the navigator. +/// +/// Also the completion coordinator: a lone foreground download opens straight in +/// the viewer; parallel downloads park in the tray so nothing steals focus. +class DownloadTrayHost extends StatefulWidget { + const DownloadTrayHost({required this.child, super.key}); + + final Widget child; + + @override + State createState() => _DownloadTrayHostState(); +} + +class _DownloadTrayHostState extends State + with WidgetsBindingObserver { + DownloadManager get _manager => DownloadManager.instance; + + StreamSubscription? _completionSub; + + /// Once any parallelism is seen, auto-open is suppressed until the tray + /// drains — so a burst of downloads never surprises the user by opening one. + bool _suppressAutoOpen = false; + bool _isForeground = true; + + /// True while the downloads overview sheet is open — the chip hides so it + /// can't be tapped again (which would stack sheets). + bool _sheetOpen = false; + + /// Route epoch at which each visible job was started, so the chip only + /// surfaces a lone download once the user has navigated away from it. + final Map _originEpoch = {}; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _isForeground = + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed; + unawaited(_manager.initialize()); + _completionSub = _manager.completions.listen(_onCompleted); + _manager.visibleJobs.addListener(_onVisibleChanged); + _manager.pendingOpen.addListener(_onPendingOpen); + _onVisibleChanged(); + // A completion notification tapped during cold start may already be queued. + WidgetsBinding.instance.addPostFrameCallback((_) => _onPendingOpen()); + } + + @override + void dispose() { + _completionSub?.cancel(); + _manager.visibleJobs.removeListener(_onVisibleChanged); + _manager.pendingOpen.removeListener(_onPendingOpen); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _isForeground = state == AppLifecycleState.resumed; + } + + void _onVisibleChanged() { + final jobs = _manager.visibleJobs.value; + if (jobs.length >= 2) { + _suppressAutoOpen = true; + } else if (jobs.isEmpty) { + _suppressAutoOpen = false; + } + // Stamp new jobs with the current route epoch; forget vanished ones. + for (final job in jobs) { + _originEpoch.putIfAbsent(job, () => DownloadRouteObserver.epoch.value); + } + _originEpoch.removeWhere((job, _) => !jobs.contains(job)); + } + + /// The chip only shows when there's something the inline UI can't already + /// convey: multiple downloads, a finished/failed one (no inline progress + /// left), or a lone download whose originating screen the user has left. + bool _shouldShowChip(List jobs, int epoch) => shouldShowDownloadChip( + sheetOpen: _sheetOpen, + jobCount: jobs.length, + anySurfaced: jobs.any( + (j) => j.isDone || j.isFailed || (_originEpoch[j] ?? epoch) != epoch, + ), + ); + + Future _openSheet() async { + if (_sheetOpen) return; + final ctx = AppRoutes.overlayContext; + if (ctx == null) return; + setState(() => _sheetOpen = true); + await showDownloadsSheet(ctx); + if (mounted) setState(() => _sheetOpen = false); + } + + void _onCompleted(DownloadJob job) { + final visible = _manager.visibleJobs.value; + final sole = visible.length == 1 && identical(visible.first, job); + final epoch = DownloadRouteObserver.epoch.value; + final onOriginScreen = (_originEpoch[job] ?? epoch) == epoch; + if (shouldAutoOpenCompletion( + foreground: _isForeground, + suppressAutoOpen: _suppressAutoOpen, + completedIsSoleVisibleJob: sole, + onOriginScreen: onOriginScreen, + )) { + _openJob(job); + } + // Otherwise it stays in the tray; the chip + its own notification let the + // user open it whenever they like. + } + + void _onPendingOpen() { + final job = _manager.pendingOpen.value; + if (job == null) return; + _manager.pendingOpen.value = null; + _openJob(job); + } + + void _openJob(DownloadJob job) { + final path = job.localPath; + _manager.markOpened(job); + final ctx = AppRoutes.overlayContext; + if (path == null || ctx == null) return; + Haptics.success(); + AppRoutes.openFileViewer(ctx, path, remoteFile: job.remoteFile); + } + + /// Distance from the bottom to float the chip. Anchored bottom-LEFT (FABs are + /// always bottom-right, so no collision), it clears the bottom nav bar (on + /// tab-root screens) plus any screen-specific bottom bar such as the chat + /// message input (via [downloadChipBottomObstruction]). + double _bottomInset(BuildContext context) { + final atRoot = + !(AppRoutes.rootNavigatorKey.currentState?.canPop() ?? false); + return MediaQuery.paddingOf(context).bottom + + (atRoot ? 72 : 16) + + downloadChipBottomObstruction.value; + } + + @override + Widget build(BuildContext context) => Stack( + children: [ + Positioned.fill(child: widget.child), + // Rebuilds on navigation and when a screen's bottom bar height changes so + // the height re-adapts; AnimatedPositioned glides the chip between the + // heights instead of jumping. + AnimatedBuilder( + animation: Listenable.merge([ + DownloadRouteObserver.epoch, + downloadChipBottomObstruction, + ]), + builder: (context, _) => AnimatedPositioned( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + left: 16, + right: 16, + bottom: _bottomInset(context), + child: Align( + alignment: Alignment.bottomLeft, + child: ValueListenableBuilder>( + valueListenable: _manager.visibleJobs, + builder: (context, jobs, _) { + if (!_shouldShowChip(jobs, DownloadRouteObserver.epoch.value)) { + return const SizedBox.shrink(); + } + // Swipe left to clear the whole tray away quickly. + return Dismissible( + key: const ValueKey('download-tray-chip'), + direction: DismissDirection.endToStart, + resizeDuration: null, + onDismissed: (_) => _manager.clearAll(), + child: _TrayChip(jobs: jobs, onTap: _openSheet), + ); + }, + ), + ), + ), + ), + ], + ); +} + +/// The pill itself. Listens to every visible job's status so its aggregate +/// progress updates live (the visible-list notifier only fires on add/remove). +class _TrayChip extends StatelessWidget { + const _TrayChip({required this.jobs, required this.onTap}); + + final List jobs; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: Listenable.merge([for (final j in jobs) j.status]), + builder: (context, _) { + final active = jobs + .where((j) => j.status.value is DownloadInProgress) + .toList(growable: false); + final doneCount = jobs.where((j) => j.isDone).length; + final failedCount = jobs.where((j) => j.isFailed).length; + + final theme = Theme.of(context); + final colors = theme.colorScheme; + + final Widget leading; + final String label; + if (active.isNotEmpty) { + final avg = + active + .map((j) => (j.status.value as DownloadInProgress).percent) + .fold(0, (a, b) => a + b) / + active.length; + final value = avg <= 0 ? null : avg / 100; + leading = SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2.5, value: value), + ); + label = active.length == 1 + ? 'Download läuft… ${avg.round()}%' + : '${active.length} Downloads… ${avg.round()}%'; + } else if (failedCount > 0 && doneCount == 0) { + leading = Icon(Icons.error_outline, size: 20, color: colors.error); + label = failedCount == 1 + ? 'Download fehlgeschlagen' + : '$failedCount Downloads fehlgeschlagen'; + } else { + leading = Icon( + Icons.check_circle_outline, + size: 20, + color: colors.primary, + ); + label = doneCount == 1 ? 'Download fertig' : '$doneCount fertig'; + } + + return Material( + color: colors.surfaceContainerHigh, + elevation: 4, + borderRadius: BorderRadius.circular(24), + shadowColor: Colors.black45, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + leading, + const SizedBox(width: 12), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Icon(Icons.expand_less, size: 20, color: colors.onSurfaceVariant), + ], + ), + ), + ), + ); + }, + ); +} diff --git a/lib/widget/downloads/download_trigger.dart b/lib/widget/downloads/download_trigger.dart new file mode 100644 index 0000000..aa5f49f --- /dev/null +++ b/lib/widget/downloads/download_trigger.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../share_intent/remote_file_ref.dart'; +import '../../utils/downloads/download_job.dart'; +import '../../utils/downloads/download_manager.dart'; +import '../confirm_dialog.dart'; + +/// Shared download trigger logic for the Files list and Talk chat bubbles. +/// +/// Owns attaching to / detaching from a [DownloadJob] and starting/cancelling a +/// download. Rendering of inline progress stays with each screen (list subtitle +/// vs. bubble overlay) via [downloadJob]. Opening the finished file, the +/// completion/parallel behaviour and error surfacing are handled centrally by +/// the downloads tray/coordinator — not here. +mixin DownloadTrigger on State { + DownloadJob? _job; + + /// Current job for inline rendering, or null when idle. + DownloadJob? get downloadJob => _job; + + /// Remote (WebDAV) path of the file this widget can download, or null when + /// the widget currently has no downloadable file (e.g. a text message). + String? get downloadRemotePath; + + /// True while a download for this widget's file is running. + bool get isDownloading => _job?.status.value is DownloadInProgress; + + /// Reattach to an in-flight job on mount. Call from `initState`. + void initDownloadTrigger() => _attach(_lookup()); + + /// Re-resolve the job after the widget's file changed. Call from + /// `didUpdateWidget` when [downloadRemotePath] may have changed. + void refreshDownloadTrigger() { + _detach(); + _attach(_lookup()); + } + + /// Detach listeners. Call from `dispose`. + void disposeDownloadTrigger() => _detach(); + + DownloadJob? _lookup() { + final path = downloadRemotePath; + return path == null ? null : DownloadManager.instance.jobFor(path); + } + + void _attach(DownloadJob? job) { + _job = job; + job?.status.addListener(_onStatus); + } + + void _detach() { + _job?.status.removeListener(_onStatus); + _job = null; + } + + void _onStatus() { + if (!mounted) return; + final job = _job; + setState(() {}); + // Once finished, the tray owns the job (open / retry / dismiss); drop our + // reference so the inline progress clears and we never touch a job the + // manager is about to dispose. + if (job != null && job.isFinished) _detach(); + } + + /// Starts (or reattaches to) the download for this widget's file. + Future startDownload({ + required String name, + RemoteFileRef? remoteFile, + }) async { + final path = downloadRemotePath; + if (path == null) return; + final job = await DownloadManager.instance.start( + remotePath: path, + name: name, + remoteFile: remoteFile, + ); + if (!mounted) return; + if (_job != job) { + _detach(); + _attach(job); + } + setState(() {}); + } + + /// Confirms and cancels the in-flight download. + void confirmCancelDownload() { + final job = _job; + if (job == null || job.isFinished) return; + ConfirmDialog( + title: 'Download abbrechen?', + content: 'Möchtest du den Download abbrechen?', + confirmButton: 'Ja, Abbrechen', + cancelButton: 'Nein', + onConfirm: () => unawaited(DownloadManager.instance.cancel(job)), + ).asDialog(context); + } +} diff --git a/lib/widget/downloads/downloads_sheet.dart b/lib/widget/downloads/downloads_sheet.dart new file mode 100644 index 0000000..8dc48a5 --- /dev/null +++ b/lib/widget/downloads/downloads_sheet.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../routing/app_routes.dart'; +import '../../utils/downloads/download_job.dart'; +import '../../utils/downloads/download_manager.dart'; +import '../../utils/haptics.dart'; +import '../../view/pages/files/data/file_type_icon.dart'; +import '../centered_leading.dart'; +import '../details_bottom_sheet.dart'; +import '../info_dialog.dart'; + +/// Overview of all active and finished-but-unopened downloads. Lets the user +/// open/switch between finished files, cancel running ones and retry failures. +/// Completes when the sheet is dismissed. +Future showDownloadsSheet(BuildContext context) { + final rootContext = context; + return showDetailsBottomSheet( + context, + header: const Padding( + padding: EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Text( + 'Downloads', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + children: (sheetContext) => [ + _DownloadsList(rootContext: rootContext, sheetContext: sheetContext), + ], + ); +} + +class _DownloadsList extends StatefulWidget { + const _DownloadsList({required this.rootContext, required this.sheetContext}); + + /// Context under the navigator, used to push the viewer after the sheet closes. + final BuildContext rootContext; + final BuildContext sheetContext; + + @override + State<_DownloadsList> createState() => _DownloadsListState(); +} + +class _DownloadsListState extends State<_DownloadsList> { + ModalRoute? _sheetRoute; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _sheetRoute = ModalRoute.of(context); + } + + @override + Widget build(BuildContext context) => + ValueListenableBuilder>( + valueListenable: DownloadManager.instance.visibleJobs, + builder: (context, jobs, _) { + if (jobs.isEmpty) { + // Nothing left to manage — close the sheet, but only if it's still + // the top route. When _open drained the tray it already popped the + // sheet and pushed the viewer; without this guard we'd pop that + // viewer straight back off. + WidgetsBinding.instance.addPostFrameCallback((_) { + final route = _sheetRoute; + if (route != null && route.isCurrent) { + Navigator.of(widget.sheetContext).pop(); + } + }); + return const SizedBox.shrink(); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final job in jobs) + _DownloadRow( + key: ValueKey(job.remotePath), + job: job, + onOpen: () => _open(job), + ), + ], + ); + }, + ); + + void _open(DownloadJob job) { + final path = job.localPath; + if (path == null) return; + Haptics.success(); + Navigator.of(widget.sheetContext).pop(); + DownloadManager.instance.markOpened(job); + AppRoutes.openFileViewer( + widget.rootContext, + path, + remoteFile: job.remoteFile, + ); + } +} + +class _DownloadRow extends StatelessWidget { + const _DownloadRow({required this.job, required this.onOpen, super.key}); + + final DownloadJob job; + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: job.status, + builder: (context, _) { + final status = job.status.value; + final theme = Theme.of(context); + + final tile = ListTile( + leading: CenteredLeading(Icon(iconForFileName(job.name))), + title: Text(job.name, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: switch (status) { + DownloadInProgress(:final percent) => Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: percent <= 0 ? null : percent / 100, + ), + ), + const SizedBox(width: 10), + Text('${percent.round()}%'), + ], + ), + DownloadDone() => const Text('Fertig – tippen zum Öffnen'), + DownloadFailed() => Text( + 'Fehlgeschlagen', + style: TextStyle(color: theme.colorScheme.error), + ), + DownloadCancelled() => const Text('Abgebrochen'), + }, + trailing: switch (status) { + DownloadInProgress() => IconButton( + icon: const Icon(Icons.close), + tooltip: 'Abbrechen', + onPressed: () => + unawaited(DownloadManager.instance.cancel(job)), + ), + DownloadFailed() => IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Erneut versuchen', + onPressed: () => unawaited(DownloadManager.instance.retry(job)), + ), + DownloadDone() => const Icon(Icons.open_in_new), + DownloadCancelled() => null, + }, + onTap: switch (status) { + DownloadDone() => onOpen, + DownloadFailed() => () => _showError(context, status), + _ => null, + }, + ); + + // Finished rows can be swiped away; running ones stay put. + if (status is DownloadInProgress) return tile; + return Dismissible( + key: ValueKey('dismiss-${job.remotePath}'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + color: theme.colorScheme.errorContainer, + child: Icon(Icons.delete_outline, color: theme.colorScheme.onErrorContainer), + ), + onDismissed: (_) => DownloadManager.instance.dismiss(job), + child: tile, + ); + }, + ); + + void _showError(BuildContext context, DownloadFailed status) { + InfoDialog.show( + context, + status.message, + title: 'Download fehlgeschlagen', + copyable: true, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index f61707c..fc3aab9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -90,6 +90,7 @@ dependencies: video_player: ^2.9.0 chewie: ^1.8.5 flutter_native_splash: ^2.4.4 + background_downloader: ^9.5.5 dev_dependencies: flutter_test: diff --git a/test/api/errors/error_mapper_test.dart b/test/api/errors/error_mapper_test.dart index e80899b..6d3d39c 100644 --- a/test/api/errors/error_mapper_test.dart +++ b/test/api/errors/error_mapper_test.dart @@ -8,7 +8,9 @@ import 'package:marianum_mobile/api/api_error.dart'; import 'package:marianum_mobile/api/errors/auth_exception.dart'; import 'package:marianum_mobile/api/errors/error_mapper.dart'; import 'package:marianum_mobile/api/errors/network_exception.dart'; +import 'package:marianum_mobile/api/errors/not_found_exception.dart'; import 'package:marianum_mobile/api/errors/parse_exception.dart'; +import 'package:nextcloud/nextcloud.dart'; void main() { group('errorToUserMessage', () { @@ -95,7 +97,7 @@ void main() { expect(errorToUserMessage(ex), const NetworkException().userMessage); }); - test('DioException badResponse maps to a server status message', () { + test('DioException badResponse maps to the server error message', () { final ex = DioException( requestOptions: RequestOptions(path: '/x'), type: DioExceptionType.badResponse, @@ -104,7 +106,65 @@ void main() { statusCode: 503, ), ); - expect(errorToUserMessage(ex), contains('503')); + // The status code lives in the technical details, not the message. + expect( + errorToUserMessage(ex), + contains('konnte die Anfrage gerade nicht verarbeiten'), + ); + expect(errorToTechnicalDetails(ex), contains('503')); + }); + }); + + group('DynamiteApiException mapping', () { + test('500 maps to the server error message without the raw dump', () { + const ex = DynamiteApiException(500, {'server': 'nginx'}, ''); + expect( + errorToUserMessage(ex), + contains('konnte die Anfrage gerade nicht verarbeiten'), + ); + expect(errorToTechnicalDetails(ex), 'HTTP 500'); + expect(errorToTechnicalDetails(ex), isNot(contains('nginx'))); + expect(errorAllowsRetry(ex), isTrue); + }); + + test('5xx details include a trimmed body preview', () { + const ex = DynamiteApiException(503, {}, ' Service\n Unavailable '); + expect(errorToTechnicalDetails(ex), 'HTTP 503 body=Service Unavailable'); + }); + + test('long bodies are capped in the details', () { + final ex = DynamiteApiException(502, const {}, 'x' * 600); + final details = errorToTechnicalDetails(ex)!; + expect(details, startsWith('HTTP 502 body=')); + expect(details, endsWith('…')); + expect(details.length, lessThan(600)); + }); + + test('401 maps to the unauthorized AuthException', () { + const ex = DynamiteApiException(401, {}, ''); + expect( + errorToUserMessage(ex), + AuthException.unauthorized().userMessage, + ); + expect(errorAllowsRetry(ex), isFalse); + }); + + test('403 maps to the forbidden AuthException', () { + const ex = DynamiteApiException(403, {}, ''); + expect(errorToUserMessage(ex), AuthException.forbidden().userMessage); + expect(errorAllowsRetry(ex), isFalse); + }); + + test('404 maps to NotFoundException', () { + const ex = DynamiteApiException(404, {}, ''); + expect(errorToUserMessage(ex), const NotFoundException().userMessage); + expect(errorAllowsRetry(ex), isFalse); + }); + + test('429 maps to a rate-limit message that allows retry', () { + const ex = DynamiteApiException(429, {}, ''); + expect(errorToUserMessage(ex), contains('Zu viele Anfragen')); + expect(errorAllowsRetry(ex), isTrue); }); }); diff --git a/test/api/retry_test.dart b/test/api/retry_test.dart new file mode 100644 index 0000000..06a1da6 --- /dev/null +++ b/test/api/retry_test.dart @@ -0,0 +1,168 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/retry.dart'; +import 'package:nextcloud/nextcloud.dart'; + +void main() { + // Instant delays so the tests never wait on real timers. + Duration noDelay(int _) => Duration.zero; + + group('isTransientServerError', () { + test('5xx DynamiteApiException is transient', () { + expect( + isTransientServerError(const DynamiteApiException(500, {}, '')), + isTrue, + ); + expect( + isTransientServerError(const DynamiteApiException(502, {}, '')), + isTrue, + ); + expect( + isTransientServerError(const DynamiteApiException(503, {}, '')), + isTrue, + ); + }); + + test('4xx DynamiteApiException is not transient', () { + expect( + isTransientServerError(const DynamiteApiException(404, {}, '')), + isFalse, + ); + expect( + isTransientServerError(const DynamiteApiException(412, {}, '')), + isFalse, + ); + expect( + isTransientServerError(const DynamiteApiException(429, {}, '')), + isFalse, + ); + }); + + test('other error types are not transient', () { + expect(isTransientServerError(TimeoutException('slow')), isFalse); + expect(isTransientServerError(const SocketException('down')), isFalse); + expect(isTransientServerError(StateError('boom')), isFalse); + }); + }); + + group('retryOnTransientError', () { + test('returns the result of a first-try success without retrying', () async { + var calls = 0; + final result = await retryOnTransientError(() async { + calls++; + return 'ok'; + }, delayFor: noDelay); + + expect(result, 'ok'); + expect(calls, 1); + }); + + test('retries transient failures until an attempt succeeds', () async { + var calls = 0; + final result = await retryOnTransientError(() async { + calls++; + if (calls < 3) throw const DynamiteApiException(500, {}, ''); + return 'ok'; + }, delayFor: noDelay); + + expect(result, 'ok'); + expect(calls, 3); + }); + + test('rethrows the last error once maxAttempts is exhausted', () async { + var calls = 0; + await expectLater( + retryOnTransientError(() async { + calls++; + throw const DynamiteApiException(503, {}, ''); + }, delayFor: noDelay), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + 503, + ), + ), + ); + expect(calls, 3); + }); + + test('rethrows non-transient errors immediately', () async { + var calls = 0; + await expectLater( + retryOnTransientError(() async { + calls++; + throw const DynamiteApiException(404, {}, ''); + }, delayFor: noDelay), + throwsA(isA()), + ); + expect(calls, 1); + }); + + test('rethrows unrelated exceptions immediately', () async { + var calls = 0; + await expectLater( + retryOnTransientError(() async { + calls++; + throw StateError('boom'); + }, delayFor: noDelay), + throwsStateError, + ); + expect(calls, 1); + }); + + test('reports upcoming attempts via onRetry and delays via delayFor', () async { + final retriesSeen = <(int, int)>[]; + final delaysRequested = []; + + await expectLater( + retryOnTransientError( + () async => throw const DynamiteApiException(500, {}, ''), + delayFor: (retry) { + delaysRequested.add(retry); + return Duration.zero; + }, + onRetry: (next, max) => retriesSeen.add((next, max)), + ), + throwsA(isA()), + ); + + expect(retriesSeen, [(2, 3), (3, 3)]); + expect(delaysRequested, [1, 2]); + }); + + test('respects a custom shouldRetry predicate', () async { + var calls = 0; + final result = await retryOnTransientError( + () async { + calls++; + if (calls < 2) throw StateError('flaky'); + return calls; + }, + delayFor: noDelay, + shouldRetry: (e) => e is StateError, + ); + + expect(result, 2); + expect(calls, 2); + }); + + test('respects a custom maxAttempts', () async { + var calls = 0; + await expectLater( + retryOnTransientError( + () async { + calls++; + throw const DynamiteApiException(500, {}, ''); + }, + maxAttempts: 5, + delayFor: noDelay, + ), + throwsA(isA()), + ); + expect(calls, 5); + }); + }); +} diff --git a/test/widget/downloads/download_tray_test.dart b/test/widget/downloads/download_tray_test.dart new file mode 100644 index 0000000..f896c47 --- /dev/null +++ b/test/widget/downloads/download_tray_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/widget/downloads/download_tray.dart'; + +void main() { + group('shouldAutoOpenCompletion', () { + test('opens a lone foreground download on its origin screen', () { + expect( + shouldAutoOpenCompletion( + foreground: true, + suppressAutoOpen: false, + completedIsSoleVisibleJob: true, + onOriginScreen: true, + ), + isTrue, + ); + }); + + test('does not open while backgrounded', () { + expect( + shouldAutoOpenCompletion( + foreground: false, + suppressAutoOpen: false, + completedIsSoleVisibleJob: true, + onOriginScreen: true, + ), + isFalse, + ); + }); + + test('does not open when other downloads are still visible', () { + expect( + shouldAutoOpenCompletion( + foreground: true, + suppressAutoOpen: false, + completedIsSoleVisibleJob: false, + onOriginScreen: true, + ), + isFalse, + ); + }); + + test('does not open once parallelism was seen (suppressed), even if now sole', () { + // A burst of parallel downloads drains one by one; the last one left must + // not surprise the user by auto-opening. + expect( + shouldAutoOpenCompletion( + foreground: true, + suppressAutoOpen: true, + completedIsSoleVisibleJob: true, + onOriginScreen: true, + ), + isFalse, + ); + }); + + test('does not open if the user left the screen it was started on', () { + // The chip surfaces instead; nothing should pop open unexpectedly. + expect( + shouldAutoOpenCompletion( + foreground: true, + suppressAutoOpen: false, + completedIsSoleVisibleJob: true, + onOriginScreen: false, + ), + isFalse, + ); + }); + }); + + group('shouldShowDownloadChip', () { + test('hidden while the overview sheet is open', () { + expect( + shouldShowDownloadChip(sheetOpen: true, jobCount: 3, anySurfaced: true), + isFalse, + ); + }); + + test('hidden when there are no jobs', () { + expect( + shouldShowDownloadChip( + sheetOpen: false, + jobCount: 0, + anySurfaced: false, + ), + isFalse, + ); + }); + + test('hidden for a lone in-progress download on its own screen', () { + // Single job, not finished, screen not left → inline progress is enough. + expect( + shouldShowDownloadChip( + sheetOpen: false, + jobCount: 1, + anySurfaced: false, + ), + isFalse, + ); + }); + + test('shown for multiple downloads', () { + expect( + shouldShowDownloadChip( + sheetOpen: false, + jobCount: 2, + anySurfaced: false, + ), + isTrue, + ); + }); + + test('shown for a lone download once surfaced (finished or screen left)', () { + expect( + shouldShowDownloadChip(sheetOpen: false, jobCount: 1, anySurfaced: true), + isTrue, + ); + }); + }); +} diff --git a/test/widget/loadable_state_primary_loading_test.dart b/test/widget/loadable_state_primary_loading_test.dart new file mode 100644 index 0000000..09209b5 --- /dev/null +++ b/test/widget/loadable_state_primary_loading_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart'; + +void main() { + Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + + const slowHint = LoadableStatePrimaryLoading.slowHintText; + + testWidgets('shows the slow hint only after the threshold elapses', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const LoadableStatePrimaryLoading(visible: true)), + ); + + expect(find.text(slowHint), findsNothing); + + await tester.pump(const Duration(seconds: 7)); + expect(find.text(slowHint), findsNothing); + + await tester.pump(const Duration(seconds: 2)); + expect(find.text(slowHint), findsOneWidget); + }); + + testWidgets('an explicit statusText takes precedence over the slow hint', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + const LoadableStatePrimaryLoading( + visible: true, + statusText: 'Erneuter Versuch (2 von 3) …', + ), + ), + ); + + expect(find.text('Erneuter Versuch (2 von 3) …'), findsOneWidget); + + // Even after the slow-hint threshold the explicit status wins. + await tester.pump(const Duration(seconds: 9)); + expect(find.text('Erneuter Versuch (2 von 3) …'), findsOneWidget); + expect(find.text(slowHint), findsNothing); + }); + + testWidgets('does not arm the slow hint while invisible', (tester) async { + await tester.pumpWidget( + wrap(const LoadableStatePrimaryLoading(visible: false)), + ); + + await tester.pump(const Duration(seconds: 20)); + expect(find.text(slowHint), findsNothing); + }); +}