added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry
This commit is contained in:
@@ -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"/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
+96
-8
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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(
|
||||
|
||||
+8
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user