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);
+ });
+}