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:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
|
||||||
android:exported="false" />
|
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
|
<!-- Receiver classes live at the package root (NOT under .widgets) because
|
||||||
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
||||||
<receiver
|
<receiver
|
||||||
@@ -127,6 +137,11 @@
|
|||||||
FirebaseMessaging.requestPermission(); without this declaration the
|
FirebaseMessaging.requestPermission(); without this declaration the
|
||||||
locally rendered push notifications are silently dropped. -->
|
locally rendered push notifications are silently dropped. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<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
|
<!-- Workmanager periodic widget refresh needs to reschedule after device
|
||||||
reboot, otherwise the widget freezes until the user opens the app. -->
|
reboot, otherwise the widget freezes until the user opens the app. -->
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
import '../api_error.dart';
|
import '../api_error.dart';
|
||||||
import '../marianumcloud/talk/talk_error.dart';
|
import '../marianumcloud/talk/talk_error.dart';
|
||||||
import 'app_exception.dart';
|
import 'app_exception.dart';
|
||||||
|
import 'auth_exception.dart';
|
||||||
import 'network_exception.dart';
|
import 'network_exception.dart';
|
||||||
|
import 'not_found_exception.dart';
|
||||||
import 'parse_exception.dart';
|
import 'parse_exception.dart';
|
||||||
import 'server_exception.dart';
|
import 'server_exception.dart';
|
||||||
import 'talk_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}) {
|
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
||||||
if (error == null) return fallback;
|
if (error == null) return fallback;
|
||||||
if (error is AppException) return error.userMessage;
|
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 (mapped != null) return mapped.userMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error is DynamiteApiException) {
|
||||||
|
return _dynamiteToAppException(error).userMessage;
|
||||||
|
}
|
||||||
|
|
||||||
if (error is SocketException) {
|
if (error is SocketException) {
|
||||||
return const NetworkException().userMessage;
|
return const NetworkException().userMessage;
|
||||||
}
|
}
|
||||||
@@ -92,6 +126,10 @@ String? errorToTechnicalDetails(Object? error) {
|
|||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
|
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
|
||||||
}
|
}
|
||||||
|
if (error is DynamiteApiException) {
|
||||||
|
final mapped = _dynamiteToAppException(error);
|
||||||
|
return mapped.technicalDetails ?? mapped.toString();
|
||||||
|
}
|
||||||
return error.toString();
|
return error.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +140,9 @@ bool errorAllowsRetry(Object? error) {
|
|||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
if (mapped != null) return mapped.allowRetry;
|
if (mapped != null) return mapped.allowRetry;
|
||||||
}
|
}
|
||||||
|
if (error is DynamiteApiException) {
|
||||||
|
return _dynamiteToAppException(error).allowRetry;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ class ServerException extends AppException {
|
|||||||
String? userMessage,
|
String? userMessage,
|
||||||
super.technicalDetails,
|
super.technicalDetails,
|
||||||
}) : super(
|
}) : super(
|
||||||
|
// Status code intentionally not in the user message — it lives in
|
||||||
|
// technicalDetails behind "Details anzeigen".
|
||||||
userMessage:
|
userMessage:
|
||||||
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,
|
allowRetry: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class TalkException extends AppException {
|
|||||||
return 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.';
|
return 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.';
|
||||||
default:
|
default:
|
||||||
if (e.code >= 500) {
|
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
|
return e.message.isNotEmpty
|
||||||
? e.message
|
? e.message
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
|
import '../../../../retry.dart';
|
||||||
import '../../webdav_api.dart';
|
import '../../webdav_api.dart';
|
||||||
import 'cacheable_file.dart';
|
import 'cacheable_file.dart';
|
||||||
import 'list_files_params.dart';
|
import 'list_files_params.dart';
|
||||||
@@ -10,7 +11,11 @@ import 'list_files_response.dart';
|
|||||||
class ListFiles extends WebdavApi<ListFilesParams> {
|
class ListFiles extends WebdavApi<ListFilesParams> {
|
||||||
ListFilesParams params;
|
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
|
// The Nextcloud root listing is significantly slower than subdirectories on
|
||||||
// our instance, so it gets a much longer ceiling. Subfolders fall back to a
|
// 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,
|
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
|
// A freshly-entered incoming share sometimes answers its first PROPFIND
|
||||||
// without the OC/NC props (no fileid / has-preview / mount-type) while the
|
// without the OC/NC props (no fileid / has-preview / mount-type) while the
|
||||||
// share mount warms up server-side — which drops thumbnails AND share
|
// share mount warms up server-side — which drops thumbnails AND share
|
||||||
// badges together. Retry a couple of times so the folder self-heals
|
// 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++) {
|
for (var attempt = 0; attempt < 2 && _looksIncomplete(files); attempt++) {
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
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);
|
return ListFilesResponse(files);
|
||||||
@@ -65,7 +80,11 @@ class ListFiles extends WebdavApi<ListFilesParams> {
|
|||||||
) async {
|
) async {
|
||||||
final davFiles =
|
final davFiles =
|
||||||
(await webdav
|
(await webdav
|
||||||
.propfind(PathUri.parse(params.path), prop: prop)
|
.propfind(
|
||||||
|
PathUri.parse(params.path),
|
||||||
|
prop: prop,
|
||||||
|
depth: WebDavDepth.one,
|
||||||
|
)
|
||||||
.timeout(timeout))
|
.timeout(timeout))
|
||||||
.toWebDavFiles();
|
.toWebDavFiles();
|
||||||
final files = davFiles.map(CacheableFile.fromDavFile).toSet();
|
final files = davFiles.map(CacheableFile.fromDavFile).toSet();
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
|||||||
super.onError,
|
super.onError,
|
||||||
required String path,
|
required String path,
|
||||||
super.renew = false,
|
super.renew = false,
|
||||||
|
void Function(int nextAttempt, int maxAttempts)? onRetry,
|
||||||
}) : super(
|
}) : super(
|
||||||
cacheTime: _cacheTimeFor(path),
|
cacheTime: _cacheTimeFor(path),
|
||||||
loader: () => ListFiles(ListFilesParams(path)).run(),
|
loader: () => ListFiles(ListFilesParams(path), onRetry: onRetry).run(),
|
||||||
fromJson: ListFilesResponse.fromJson,
|
fromJson: ListFilesResponse.fromJson,
|
||||||
onUpdate: onUpdate,
|
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/dark_app_theme.dart';
|
||||||
import 'theming/light_app_theme.dart';
|
import 'theming/light_app_theme.dart';
|
||||||
import 'utils/app_paths.dart';
|
import 'utils/app_paths.dart';
|
||||||
|
import 'utils/downloads/download_manager.dart';
|
||||||
import 'view/login/login.dart';
|
import 'view/login/login.dart';
|
||||||
import 'view/login/post_login_splash.dart';
|
import 'view/login/post_login_splash.dart';
|
||||||
import 'widget/app_progress_indicator.dart';
|
import 'widget/app_progress_indicator.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
import 'widget/debug/cache_view.dart';
|
import 'widget/debug/cache_view.dart';
|
||||||
|
import 'widget/downloads/download_tray.dart';
|
||||||
import 'widget_data/widget_sync.dart';
|
import 'widget_data/widget_sync.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
@@ -98,6 +100,15 @@ Future<void> main() async {
|
|||||||
await PushRenderer.ensureChannels();
|
await PushRenderer.ensureChannels();
|
||||||
FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage);
|
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
|
// Wire up the home-screen widget bridge before runApp so any widget render
|
||||||
// triggered during startup hits initialised native storage.
|
// triggered during startup hits initialised native storage.
|
||||||
await WidgetSync.ensureInitialized();
|
await WidgetSync.ensureInitialized();
|
||||||
@@ -285,8 +296,14 @@ class _MainState extends State<Main> {
|
|||||||
checkerboardRasterCacheImages:
|
checkerboardRasterCacheImages:
|
||||||
devToolsSettings.checkerboardRasterCacheImages,
|
devToolsSettings.checkerboardRasterCacheImages,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
navigatorKey: AppRoutes.rootNavigatorKey,
|
||||||
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
// 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 [
|
localizationsDelegates: const [
|
||||||
...GlobalMaterialLocalizations.delegates,
|
...GlobalMaterialLocalizations.delegates,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
@@ -304,7 +321,10 @@ class _MainState extends State<Main> {
|
|||||||
// black flash.
|
// black flash.
|
||||||
builder: (context, child) => ColoredBox(
|
builder: (context, child) => ColoredBox(
|
||||||
color: LightAppTheme.marianumRed,
|
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(
|
home: LoaderOverlay(
|
||||||
child: Breaker(
|
child: Breaker(
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ class AppRoutes {
|
|||||||
static final RouteObserver<PageRoute<dynamic>> chatRouteObserver =
|
static final RouteObserver<PageRoute<dynamic>> chatRouteObserver =
|
||||||
RouteObserver<PageRoute<dynamic>>();
|
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) {
|
static void openFolder(BuildContext context, List<String> path) {
|
||||||
pushScreen(context, withNavBar: false, screen: Files(path: path));
|
pushScreen(context, withNavBar: false, screen: Files(path: path));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ abstract class LoadableState<TState> with _$LoadableState<TState> {
|
|||||||
required int? lastFetch,
|
required int? lastFetch,
|
||||||
required void Function()? reFetch,
|
required void Function()? reFetch,
|
||||||
required LoadingError? error,
|
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>;
|
}) = _LoadableState<TState>;
|
||||||
|
|
||||||
bool _hasError() => error != null;
|
bool _hasError() => error != null;
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ T _$identity<T>(T value) => value;
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$LoadableState<TState> {
|
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
|
/// Create a copy of LoadableState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -25,16 +27,16 @@ $LoadableStateCopyWith<TState, LoadableState<TState>> get copyWith => _$Loadable
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory $LoadableStateCopyWith(LoadableState<TState> value, $Res Function(LoadableState<TState>) _then) = _$LoadableStateCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of LoadableState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_self.copyWith(
|
||||||
isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
|
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 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 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 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 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
|
/// 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) {
|
switch (_that) {
|
||||||
case _LoadableState() when $default != null:
|
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();
|
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) {
|
switch (_that) {
|
||||||
case _LoadableState():
|
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');
|
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) {
|
switch (_that) {
|
||||||
case _LoadableState() when $default != null:
|
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;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -222,7 +225,7 @@ return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.e
|
|||||||
|
|
||||||
|
|
||||||
class _LoadableState<TState> extends LoadableState<TState> {
|
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;
|
@override final bool isLoading;
|
||||||
@@ -230,6 +233,9 @@ class _LoadableState<TState> extends LoadableState<TState> {
|
|||||||
@override final int? lastFetch;
|
@override final int? lastFetch;
|
||||||
@override final void Function()? reFetch;
|
@override final void Function()? reFetch;
|
||||||
@override final LoadingError? error;
|
@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
|
/// Create a copy of LoadableState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@@ -241,16 +247,16 @@ _$LoadableStateCopyWith<TState, _LoadableState<TState>> get copyWith => __$Loada
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory _$LoadableStateCopyWith(_LoadableState<TState> value, $Res Function(_LoadableState<TState>) _then) = __$LoadableStateCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of LoadableState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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>(
|
return _then(_LoadableState<TState>(
|
||||||
isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
|
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 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 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 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 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(
|
Expanded(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
|
LoadableStatePrimaryLoading(
|
||||||
|
visible: showPrimaryLoading,
|
||||||
|
statusText: loadableState.statusText,
|
||||||
|
),
|
||||||
LoadableStateBackgroundLoading(
|
LoadableStateBackgroundLoading(
|
||||||
visible: showBackgroundLoading,
|
visible: showBackgroundLoading,
|
||||||
),
|
),
|
||||||
|
|||||||
+96
-8
@@ -1,17 +1,105 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../../../widget/app_progress_indicator.dart';
|
import '../../../../../widget/app_progress_indicator.dart';
|
||||||
import 'loadable_state_consumer.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;
|
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
|
@override
|
||||||
Widget build(BuildContext context) => AnimatedOpacity(
|
State<LoadableStatePrimaryLoading> createState() =>
|
||||||
opacity: visible ? 1.0 : 0.0,
|
_LoadableStatePrimaryLoadingState();
|
||||||
duration: LoadableStateConsumer.animationDuration,
|
}
|
||||||
curve: Curves.easeInOut,
|
|
||||||
child: const Center(child: AppProgressIndicator.large()),
|
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,
|
lastFetch: state.lastFetch,
|
||||||
reFetch: retry,
|
reFetch: retry,
|
||||||
error: state.error,
|
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>>(
|
on<DataGathered<TState>>(
|
||||||
(event, emit) => emit(
|
(event, emit) => emit(
|
||||||
LoadableState(
|
LoadableState(
|
||||||
|
|||||||
+8
@@ -19,4 +19,12 @@ class Error<TState> extends LoadableHydratedBlocEvent<TState> {
|
|||||||
|
|
||||||
class RefetchStarted<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> {}
|
class Reset<TState> extends LoadableHydratedBlocEvent<TState> {}
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ class FilesBloc
|
|||||||
);
|
);
|
||||||
add(Emit((s) => s.copyWith(listing: cached)));
|
add(Emit((s) => s.copyWith(listing: cached)));
|
||||||
},
|
},
|
||||||
|
onRetry: (next, max) {
|
||||||
|
if (isStale()) return;
|
||||||
|
add(LoadingStatus('Erneuter Versuch ($next von $max) …'));
|
||||||
|
},
|
||||||
onError: (e) => capturedError = e,
|
onError: (e) => capturedError = e,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class FilesDataProvider {
|
|||||||
void Function(ListFilesResponse)? onCacheData,
|
void Function(ListFilesResponse)? onCacheData,
|
||||||
void Function(Object)? onError,
|
void Function(Object)? onError,
|
||||||
bool renew = false,
|
bool renew = false,
|
||||||
|
void Function(int nextAttempt, int maxAttempts)? onRetry,
|
||||||
}) => resolveFromCache<ListFilesResponse>(
|
}) => resolveFromCache<ListFilesResponse>(
|
||||||
(onUpdate, onError) => ListFilesCache(
|
(onUpdate, onError) => ListFilesCache(
|
||||||
path: path,
|
path: path,
|
||||||
@@ -28,6 +29,7 @@ class FilesDataProvider {
|
|||||||
onCacheData: onCacheData,
|
onCacheData: onCacheData,
|
||||||
onError: onError,
|
onError: onError,
|
||||||
renew: renew,
|
renew: renew,
|
||||||
|
onRetry: onRetry,
|
||||||
),
|
),
|
||||||
onError: onError,
|
onError: onError,
|
||||||
operationName: 'listFiles',
|
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;
|
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) {
|
String? _extensionOf(String name) {
|
||||||
final dot = name.lastIndexOf('.');
|
final dot = name.lastIndexOf('.');
|
||||||
if (dot <= 0 || dot == name.length - 1) return null;
|
if (dot <= 0 || dot == name.length - 1) return null;
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import '../../../../model/endpoint_data.dart';
|
|||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../share_intent/remote_file_ref.dart';
|
import '../../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.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/file_clipboard.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
import '../../../../utils/haptics.dart';
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
|
import '../../../../widget/downloads/download_trigger.dart';
|
||||||
import '../../../../widget/info_dialog.dart';
|
import '../../../../widget/info_dialog.dart';
|
||||||
import '../../talk/widgets/highlighted_linkify.dart';
|
import '../../talk/widgets/highlighted_linkify.dart';
|
||||||
import '../sharing/share_sheet.dart';
|
import '../sharing/share_sheet.dart';
|
||||||
@@ -43,101 +44,32 @@ class FileElement extends StatefulWidget {
|
|||||||
State<FileElement> createState() => _FileElementState();
|
State<FileElement> createState() => _FileElementState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FileElementState extends State<FileElement> {
|
class _FileElementState extends State<FileElement>
|
||||||
DownloadJob? _job;
|
with DownloadTrigger<FileElement> {
|
||||||
|
@override
|
||||||
|
String? get downloadRemotePath =>
|
||||||
|
widget.file.isDirectory ? null : widget.file.path;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_attachJob(DownloadManager.instance.jobFor(widget.file.path));
|
initDownloadTrigger();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant FileElement oldWidget) {
|
void didUpdateWidget(covariant FileElement oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.file.path != widget.file.path) {
|
if (oldWidget.file.path != widget.file.path) refreshDownloadTrigger();
|
||||||
_detachJob();
|
|
||||||
_attachJob(DownloadManager.instance.jobFor(widget.file.path));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_detachJob();
|
disposeDownloadTrigger();
|
||||||
super.dispose();
|
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() {
|
Widget? _subtitle() {
|
||||||
final status = _job?.status.value;
|
final status = downloadJob?.status.value;
|
||||||
if (status is DownloadInProgress) {
|
if (status is DownloadInProgress) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -180,12 +112,14 @@ class _FileElementState extends State<FileElement> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final status = _job?.status.value;
|
if (isDownloading) {
|
||||||
if (status is DownloadInProgress) {
|
confirmCancelDownload();
|
||||||
_confirmCancel();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_startDownload();
|
startDownload(
|
||||||
|
name: widget.file.name,
|
||||||
|
remoteFile: RemoteFileRef.fromCacheable(widget.file),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All paths here are relative to the WebDAV root (matching CacheableFile.path).
|
// 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 '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../extensions/text.dart';
|
import '../../../../extensions/text.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
|
||||||
import '../../../../share_intent/remote_file_ref.dart';
|
import '../../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.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 '../../../../utils/haptics.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/downloads/download_trigger.dart';
|
||||||
import '../../../../widget/info_dialog.dart';
|
|
||||||
import '../data/chat_bubble_styles.dart';
|
import '../data/chat_bubble_styles.dart';
|
||||||
import '../data/chat_message.dart';
|
import '../data/chat_message.dart';
|
||||||
import 'answer_reference.dart';
|
import 'answer_reference.dart';
|
||||||
@@ -58,97 +56,40 @@ class ChatBubble extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ChatBubbleState extends State<ChatBubble>
|
class _ChatBubbleState extends State<ChatBubble>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin, DownloadTrigger<ChatBubble> {
|
||||||
late ChatMessage message;
|
late ChatMessage message;
|
||||||
DownloadJob? _job;
|
|
||||||
|
|
||||||
Offset _position = Offset.zero;
|
Offset _position = Offset.zero;
|
||||||
Offset _dragStartPosition = Offset.zero;
|
Offset _dragStartPosition = Offset.zero;
|
||||||
bool _swipeActionArmed = false;
|
bool _swipeActionArmed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get downloadRemotePath =>
|
||||||
|
widget.bubbleData.messageParameters?['file']?.path;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final filePath = widget.bubbleData.messageParameters?['file']?.path;
|
initDownloadTrigger();
|
||||||
if (filePath != null) _attachJob(DownloadManager.instance.jobFor(filePath));
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant ChatBubble oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
final oldPath = oldWidget.bubbleData.messageParameters?['file']?.path;
|
||||||
|
if (oldPath != downloadRemotePath) refreshDownloadTrigger();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_detachJob();
|
disposeDownloadTrigger();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _attachJob(DownloadJob? job) {
|
void _startFileDownload() {
|
||||||
_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 {
|
|
||||||
final file = message.file;
|
final file = message.file;
|
||||||
final filePath = file?.path;
|
if (file == null) return;
|
||||||
if (file == null || filePath == null) return;
|
startDownload(name: file.name, remoteFile: RemoteFileRef.fromTalk(file));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _rendersAsCommentBubble =>
|
bool get _rendersAsCommentBubble =>
|
||||||
@@ -236,8 +177,8 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.file == null) return;
|
if (message.file == null) return;
|
||||||
if (_job?.status.value is DownloadInProgress) {
|
if (isDownloading) {
|
||||||
_confirmCancel();
|
confirmCancelDownload();
|
||||||
} else {
|
} else {
|
||||||
_startFileDownload();
|
_startFileDownload();
|
||||||
}
|
}
|
||||||
@@ -349,7 +290,7 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
timeIconColor: widget.timeIconColor,
|
timeIconColor: widget.timeIconColor,
|
||||||
showActorDisplayName: showActorDisplayName,
|
showActorDisplayName: showActorDisplayName,
|
||||||
showBubbleTime: showBubbleTime,
|
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 '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
|
import '../../../../widget/downloads/download_tray.dart';
|
||||||
import '../../../../widget/emoji_picker_dialog.dart';
|
import '../../../../widget/emoji_picker_dialog.dart';
|
||||||
import '../../../../widget/file_pick.dart';
|
import '../../../../widget/file_pick.dart';
|
||||||
import '../../../../widget/focus_behaviour.dart';
|
import '../../../../widget/focus_behaviour.dart';
|
||||||
@@ -34,8 +35,17 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
final TextEditingController _textBoxController = TextEditingController();
|
final TextEditingController _textBoxController = TextEditingController();
|
||||||
final AsyncActionController _sendController = AsyncActionController();
|
final AsyncActionController _sendController = AsyncActionController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
final GlobalKey _sizeKey = GlobalKey();
|
||||||
String? _sendError;
|
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) {
|
void share(List<String> uploadedRemotePaths) {
|
||||||
shareFilesToChat(
|
shareFilesToChat(
|
||||||
token: widget.sendToToken,
|
token: widget.sendToToken,
|
||||||
@@ -104,6 +114,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
downloadChipBottomObstruction.value = 0;
|
||||||
_sendController.dispose();
|
_sendController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -236,7 +247,9 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _publishHeight());
|
||||||
return Stack(
|
return Stack(
|
||||||
|
key: _sizeKey,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.bottomLeft,
|
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
|
/// custom event, etc.). All detail sheets in the app share this layout: drag
|
||||||
/// handle on top, default theme background, optional ListTile-style header
|
/// handle on top, default theme background, optional ListTile-style header
|
||||||
/// followed by a divider, scrollable body below.
|
/// followed by a divider, scrollable body below.
|
||||||
void showDetailsBottomSheet(
|
Future<void> showDetailsBottomSheet(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Widget? header,
|
Widget? header,
|
||||||
required List<Widget> Function(BuildContext sheetContext) children,
|
required List<Widget> Function(BuildContext sheetContext) children,
|
||||||
}) {
|
}) {
|
||||||
showModalBottomSheet<void>(
|
return showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
showDragHandle: 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
|
video_player: ^2.9.0
|
||||||
chewie: ^1.8.5
|
chewie: ^1.8.5
|
||||||
flutter_native_splash: ^2.4.4
|
flutter_native_splash: ^2.4.4
|
||||||
|
background_downloader: ^9.5.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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/auth_exception.dart';
|
||||||
import 'package:marianum_mobile/api/errors/error_mapper.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/network_exception.dart';
|
||||||
|
import 'package:marianum_mobile/api/errors/not_found_exception.dart';
|
||||||
import 'package:marianum_mobile/api/errors/parse_exception.dart';
|
import 'package:marianum_mobile/api/errors/parse_exception.dart';
|
||||||
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('errorToUserMessage', () {
|
group('errorToUserMessage', () {
|
||||||
@@ -95,7 +97,7 @@ void main() {
|
|||||||
expect(errorToUserMessage(ex), const NetworkException().userMessage);
|
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(
|
final ex = DioException(
|
||||||
requestOptions: RequestOptions(path: '/x'),
|
requestOptions: RequestOptions(path: '/x'),
|
||||||
type: DioExceptionType.badResponse,
|
type: DioExceptionType.badResponse,
|
||||||
@@ -104,7 +106,65 @@ void main() {
|
|||||||
statusCode: 503,
|
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