added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry

This commit is contained in:
2026-07-06 15:43:17 +02:00
parent 614ab159af
commit 124b1a5177
34 changed files with 1866 additions and 409 deletions
+15
View File
@@ -67,6 +67,16 @@
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
<!-- background_downloader runs large/long downloads in a foreground
service (WorkManager) so they survive the app being backgrounded and
beat the default 9-minute WorkManager timeout. On API 34+ the
dataSync foreground service type must be declared explicitly here;
see the FOREGROUND_SERVICE_DATA_SYNC permission below. -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<!-- Receiver classes live at the package root (NOT under .widgets) because
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver
@@ -127,6 +137,11 @@
FirebaseMessaging.requestPermission(); without this declaration the
locally rendered push notifications are silently dropped. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- background_downloader foreground service for large/long downloads that
must keep running while the app is backgrounded. DATA_SYNC is the
required type declaration on Android 14+ (API 34+). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<!-- Workmanager periodic widget refresh needs to reschedule after device
reboot, otherwise the widget freezes until the user opens the app. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
+41
View File
@@ -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;
}
+4 -1
View File
@@ -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,
);
}
+2 -1
View File
@@ -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
@@ -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> {
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<ListFilesParams> {
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<void>.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<ListFilesParams> {
) 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();
@@ -17,9 +17,10 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
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,
) {
+38
View File
@@ -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<T> retryOnTransientError<T>(
Future<T> 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<void>.delayed(delayFor(attemptNo));
}
}
}
+22 -2
View File
@@ -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<void> main() async {
@@ -98,6 +100,15 @@ Future<void> 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<Main> {
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<Main> {
// 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(
+11
View File
@@ -49,6 +49,17 @@ class AppRoutes {
static final RouteObserver<PageRoute<dynamic>> chatRouteObserver =
RouteObserver<PageRoute<dynamic>>();
/// 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<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>();
/// 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<String> path) {
pushScreen(context, withNavBar: false, screen: Files(path: path));
}
@@ -14,6 +14,9 @@ abstract class LoadableState<TState> with _$LoadableState<TState> {
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<TState>;
bool _hasError() => error != null;
@@ -14,7 +14,9 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$LoadableState<TState> {
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<TState, LoadableState<TState>> get copyWith => _$Loadable
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is LoadableState<TState>&&(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<TState>&&(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<TState,$Res> {
factory $LoadableStateCopyWith(LoadableState<TState> value, $Res Function(LoadableState<TState>) _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<TState,$Res>
/// 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 extends Object?>(TResult Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(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 extends Object?>(TResult Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(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 extends Object?>(TResult? Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(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<TState> extends LoadableState<TState> {
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<TState> extends LoadableState<TState> {
@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<TState, _LoadableState<TState>> get copyWith => __$Loada
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadableState<TState>&&(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<TState>&&(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<TState,$Res> implements $LoadableSt
factory _$LoadableStateCopyWith(_LoadableState<TState> value, $Res Function(_LoadableState<TState>) _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<TState,$Res>
/// 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<TState>(
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?,
));
}
@@ -111,7 +111,10 @@ class LoadableStateConsumer<
Expanded(
child: Stack(
children: [
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
LoadableStatePrimaryLoading(
visible: showPrimaryLoading,
statusText: loadableState.statusText,
),
LoadableStateBackgroundLoading(
visible: showBackgroundLoading,
),
@@ -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<LoadableStatePrimaryLoading> createState() =>
_LoadableStatePrimaryLoadingState();
}
class _LoadableStatePrimaryLoadingState
extends State<LoadableStatePrimaryLoading> {
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,
),
),
),
),
],
),
),
);
}
}
@@ -35,10 +35,24 @@ abstract class LoadableHydratedBloc<
lastFetch: state.lastFetch,
reFetch: retry,
error: state.error,
statusText: state.statusText,
),
);
});
on<LoadingStatus<TState>>(
(event, emit) => emit(
LoadableState(
isLoading: state.isLoading,
data: innerState,
lastFetch: state.lastFetch,
reFetch: state.reFetch,
error: state.error,
statusText: event.statusText,
),
),
);
on<DataGathered<TState>>(
(event, emit) => emit(
LoadableState(
@@ -19,4 +19,12 @@ class Error<TState> extends LoadableHydratedBlocEvent<TState> {
class RefetchStarted<TState> extends LoadableHydratedBlocEvent<TState> {}
/// 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<TState> extends LoadableHydratedBlocEvent<TState> {
final String? statusText;
LoadingStatus(this.statusText);
}
class Reset<TState> extends LoadableHydratedBlocEvent<TState> {}
@@ -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) {
@@ -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<ListFilesResponse>(
(onUpdate, onError) => ListFilesCache(
path: path,
@@ -28,6 +29,7 @@ class FilesDataProvider {
onCacheData: onCacheData,
onError: onError,
renew: renew,
onRetry: onRetry,
),
onError: onError,
operationName: 'listFiles',
-151
View File
@@ -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<DownloadStatus> 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<String, DownloadJob> _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<DownloadJob> 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);
}
}
+78
View File
@@ -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);
/// 0100, 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<DownloadStatus> 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();
}
+372
View File
@@ -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<String, DownloadJob> _jobs = {}; // keyed by remotePath
final Map<String, DownloadJob> _byTaskId = {};
final Map<String, bd.DownloadTask> _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<List<DownloadJob>> 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<DownloadJob> _completions =
StreamController<DownloadJob>.broadcast();
Stream<DownloadJob> 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<DownloadJob?> pendingOpen = ValueNotifier(null);
bool _initialized = false;
StreamSubscription<bd.TaskUpdate>? _updatesSub;
/// Wires up the native downloader once. Safe to call repeatedly.
Future<void> 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<DownloadJob> 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<void> 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<DownloadJob> 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<void> _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<void> _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<DownloadJob?> _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<String, dynamic>;
final ref = map['ref'] as Map<String, dynamic>?;
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;
}
}
-51
View File
@@ -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();
}
}
@@ -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;
+17 -83
View File
@@ -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<FileElement> createState() => _FileElementState();
}
class _FileElementState extends State<FileElement> {
DownloadJob? _job;
class _FileElementState extends State<FileElement>
with DownloadTrigger<FileElement> {
@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<void> _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<void>(
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<FileElement> {
);
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).
+22 -81
View File
@@ -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<ChatBubble>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, DownloadTrigger<ChatBubble> {
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<void> _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<ChatBubble>
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<ChatBubble>
timeIconColor: widget.timeIconColor,
showActorDisplayName: showActorDisplayName,
showBubbleTime: showBubbleTime,
downloadJob: _job,
downloadJob: downloadJob,
),
),
),
@@ -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<ChatTextfield> {
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<String> uploadedRemotePaths) {
shareFilesToChat(
token: widget.sendToToken,
@@ -104,6 +114,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
@override
void dispose() {
downloadChipBottomObstruction.value = 0;
_sendController.dispose();
_focusNode.dispose();
super.dispose();
@@ -236,7 +247,9 @@ class _ChatTextfieldState extends State<ChatTextfield> {
}
}
WidgetsBinding.instance.addPostFrameCallback((_) => _publishHeight());
return Stack(
key: _sizeKey,
children: <Widget>[
Align(
alignment: Alignment.bottomLeft,
+2 -2
View File
@@ -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<void> showDetailsBottomSheet(
BuildContext context, {
Widget? header,
required List<Widget> Function(BuildContext sheetContext) children,
}) {
showModalBottomSheet<void>(
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
+348
View File
@@ -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<double> 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<int> epoch = ValueNotifier(0);
static void _bump(Route<dynamic>? route) {
if (route is PageRoute) epoch.value++;
}
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) =>
_bump(route);
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) =>
_bump(route);
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) =>
_bump(newRoute);
@override
void didRemove(Route<dynamic> route, Route<dynamic>? 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<DownloadTrayHost> createState() => _DownloadTrayHostState();
}
class _DownloadTrayHostState extends State<DownloadTrayHost>
with WidgetsBindingObserver {
DownloadManager get _manager => DownloadManager.instance;
StreamSubscription<DownloadJob>? _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<DownloadJob, int> _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<DownloadJob> jobs, int epoch) => shouldShowDownloadChip(
sheetOpen: _sheetOpen,
jobCount: jobs.length,
anySurfaced: jobs.any(
(j) => j.isDone || j.isFailed || (_originEpoch[j] ?? epoch) != epoch,
),
);
Future<void> _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<List<DownloadJob>>(
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<DownloadJob> 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<double>(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),
],
),
),
),
);
},
);
}
+100
View File
@@ -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<T extends StatefulWidget> on State<T> {
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<void> 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);
}
}
+182
View File
@@ -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<void> 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<dynamic>? _sheetRoute;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_sheetRoute = ModalRoute.of(context);
}
@override
Widget build(BuildContext context) =>
ValueListenableBuilder<List<DownloadJob>>(
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,
);
}
}
+1
View File
@@ -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:
+62 -2
View File
@@ -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);
});
});
+168
View File
@@ -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<DynamiteApiException>().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<DynamiteApiException>()),
);
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 = <int>[];
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<DynamiteApiException>()),
);
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<DynamiteApiException>()),
);
expect(calls, 5);
});
});
}
@@ -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,
);
});
});
}
@@ -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);
});
}