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

This commit is contained in:
2026-07-06 15:43:17 +02:00
parent 614ab159af
commit 124b1a5177
34 changed files with 1866 additions and 409 deletions
@@ -14,6 +14,9 @@ abstract class LoadableState<TState> with _$LoadableState<TState> {
required int? lastFetch,
required void Function()? reFetch,
required LoadingError? error,
// Transient progress note under the primary loading spinner (e.g. retry
// progress). Lives only within one fetch cycle — never persisted.
String? statusText,
}) = _LoadableState<TState>;
bool _hasError() => error != null;
@@ -14,7 +14,9 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$LoadableState<TState> {
bool get isLoading; TState? get data; int? get lastFetch; void Function()? get reFetch; LoadingError? get error;
bool get isLoading; TState? get data; int? get lastFetch; void Function()? get reFetch; LoadingError? get error;// Transient progress note under the primary loading spinner (e.g. retry
// progress). Lives only within one fetch cycle — never persisted.
String? get statusText;
/// Create a copy of LoadableState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -25,16 +27,16 @@ $LoadableStateCopyWith<TState, LoadableState<TState>> get copyWith => _$Loadable
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is LoadableState<TState>&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.lastFetch, lastFetch) || other.lastFetch == lastFetch)&&(identical(other.reFetch, reFetch) || other.reFetch == reFetch)&&(identical(other.error, error) || other.error == error));
return identical(this, other) || (other.runtimeType == runtimeType&&other is LoadableState<TState>&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.lastFetch, lastFetch) || other.lastFetch == lastFetch)&&(identical(other.reFetch, reFetch) || other.reFetch == reFetch)&&(identical(other.error, error) || other.error == error)&&(identical(other.statusText, statusText) || other.statusText == statusText));
}
@override
int get hashCode => Object.hash(runtimeType,isLoading,const DeepCollectionEquality().hash(data),lastFetch,reFetch,error);
int get hashCode => Object.hash(runtimeType,isLoading,const DeepCollectionEquality().hash(data),lastFetch,reFetch,error,statusText);
@override
String toString() {
return 'LoadableState<$TState>(isLoading: $isLoading, data: $data, lastFetch: $lastFetch, reFetch: $reFetch, error: $error)';
return 'LoadableState<$TState>(isLoading: $isLoading, data: $data, lastFetch: $lastFetch, reFetch: $reFetch, error: $error, statusText: $statusText)';
}
@@ -45,7 +47,7 @@ abstract mixin class $LoadableStateCopyWith<TState,$Res> {
factory $LoadableStateCopyWith(LoadableState<TState> value, $Res Function(LoadableState<TState>) _then) = _$LoadableStateCopyWithImpl;
@useResult
$Res call({
bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error
bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error, String? statusText
});
@@ -62,14 +64,15 @@ class _$LoadableStateCopyWithImpl<TState,$Res>
/// Create a copy of LoadableState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isLoading = null,Object? data = freezed,Object? lastFetch = freezed,Object? reFetch = freezed,Object? error = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? isLoading = null,Object? data = freezed,Object? lastFetch = freezed,Object? reFetch = freezed,Object? error = freezed,Object? statusText = freezed,}) {
return _then(_self.copyWith(
isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as TState?,lastFetch: freezed == lastFetch ? _self.lastFetch : lastFetch // ignore: cast_nullable_to_non_nullable
as int?,reFetch: freezed == reFetch ? _self.reFetch : reFetch // ignore: cast_nullable_to_non_nullable
as void Function()?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as LoadingError?,
as LoadingError?,statusText: freezed == statusText ? _self.statusText : statusText // ignore: cast_nullable_to_non_nullable
as String?,
));
}
/// Create a copy of LoadableState
@@ -166,10 +169,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error, String? statusText)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _LoadableState() when $default != null:
return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error);case _:
return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error,_that.statusText);case _:
return orElse();
}
@@ -187,10 +190,10 @@ return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.e
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error, String? statusText) $default,) {final _that = this;
switch (_that) {
case _LoadableState():
return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error);case _:
return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error,_that.statusText);case _:
throw StateError('Unexpected subclass');
}
@@ -207,10 +210,10 @@ return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.e
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error, String? statusText)? $default,) {final _that = this;
switch (_that) {
case _LoadableState() when $default != null:
return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error);case _:
return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.error,_that.statusText);case _:
return null;
}
@@ -222,7 +225,7 @@ return $default(_that.isLoading,_that.data,_that.lastFetch,_that.reFetch,_that.e
class _LoadableState<TState> extends LoadableState<TState> {
const _LoadableState({required this.isLoading, required this.data, required this.lastFetch, required this.reFetch, required this.error}): super._();
const _LoadableState({required this.isLoading, required this.data, required this.lastFetch, required this.reFetch, required this.error, this.statusText}): super._();
@override final bool isLoading;
@@ -230,6 +233,9 @@ class _LoadableState<TState> extends LoadableState<TState> {
@override final int? lastFetch;
@override final void Function()? reFetch;
@override final LoadingError? error;
// Transient progress note under the primary loading spinner (e.g. retry
// progress). Lives only within one fetch cycle — never persisted.
@override final String? statusText;
/// Create a copy of LoadableState
/// with the given fields replaced by the non-null parameter values.
@@ -241,16 +247,16 @@ _$LoadableStateCopyWith<TState, _LoadableState<TState>> get copyWith => __$Loada
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadableState<TState>&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.lastFetch, lastFetch) || other.lastFetch == lastFetch)&&(identical(other.reFetch, reFetch) || other.reFetch == reFetch)&&(identical(other.error, error) || other.error == error));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadableState<TState>&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.lastFetch, lastFetch) || other.lastFetch == lastFetch)&&(identical(other.reFetch, reFetch) || other.reFetch == reFetch)&&(identical(other.error, error) || other.error == error)&&(identical(other.statusText, statusText) || other.statusText == statusText));
}
@override
int get hashCode => Object.hash(runtimeType,isLoading,const DeepCollectionEquality().hash(data),lastFetch,reFetch,error);
int get hashCode => Object.hash(runtimeType,isLoading,const DeepCollectionEquality().hash(data),lastFetch,reFetch,error,statusText);
@override
String toString() {
return 'LoadableState<$TState>(isLoading: $isLoading, data: $data, lastFetch: $lastFetch, reFetch: $reFetch, error: $error)';
return 'LoadableState<$TState>(isLoading: $isLoading, data: $data, lastFetch: $lastFetch, reFetch: $reFetch, error: $error, statusText: $statusText)';
}
@@ -261,7 +267,7 @@ abstract mixin class _$LoadableStateCopyWith<TState,$Res> implements $LoadableSt
factory _$LoadableStateCopyWith(_LoadableState<TState> value, $Res Function(_LoadableState<TState>) _then) = __$LoadableStateCopyWithImpl;
@override @useResult
$Res call({
bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error
bool isLoading, TState? data, int? lastFetch, void Function()? reFetch, LoadingError? error, String? statusText
});
@@ -278,14 +284,15 @@ class __$LoadableStateCopyWithImpl<TState,$Res>
/// Create a copy of LoadableState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isLoading = null,Object? data = freezed,Object? lastFetch = freezed,Object? reFetch = freezed,Object? error = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? isLoading = null,Object? data = freezed,Object? lastFetch = freezed,Object? reFetch = freezed,Object? error = freezed,Object? statusText = freezed,}) {
return _then(_LoadableState<TState>(
isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as TState?,lastFetch: freezed == lastFetch ? _self.lastFetch : lastFetch // ignore: cast_nullable_to_non_nullable
as int?,reFetch: freezed == reFetch ? _self.reFetch : reFetch // ignore: cast_nullable_to_non_nullable
as void Function()?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as LoadingError?,
as LoadingError?,statusText: freezed == statusText ? _self.statusText : statusText // ignore: cast_nullable_to_non_nullable
as String?,
));
}
@@ -111,7 +111,10 @@ class LoadableStateConsumer<
Expanded(
child: Stack(
children: [
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
LoadableStatePrimaryLoading(
visible: showPrimaryLoading,
statusText: loadableState.statusText,
),
LoadableStateBackgroundLoading(
visible: showBackgroundLoading,
),
@@ -1,17 +1,105 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../../widget/app_progress_indicator.dart';
import 'loadable_state_consumer.dart';
class LoadableStatePrimaryLoading extends StatelessWidget {
/// Full-screen spinner for the initial load. A minutes-long spinner without
/// feedback looks stuck (the Nextcloud root listing can take that long), so
/// after [slowHintAfter] a generic slow-server note fades in below it. An
/// explicit [statusText] (e.g. retry progress from the bloc) always takes
/// precedence over the timer-based hint.
class LoadableStatePrimaryLoading extends StatefulWidget {
final bool visible;
const LoadableStatePrimaryLoading({required this.visible, super.key});
final String? statusText;
final Duration slowHintAfter;
const LoadableStatePrimaryLoading({
required this.visible,
this.statusText,
this.slowHintAfter = const Duration(seconds: 8),
super.key,
});
static const String slowHintText = 'Der Server antwortet verzögert …';
@override
Widget build(BuildContext context) => AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
child: const Center(child: AppProgressIndicator.large()),
);
State<LoadableStatePrimaryLoading> createState() =>
_LoadableStatePrimaryLoadingState();
}
class _LoadableStatePrimaryLoadingState
extends State<LoadableStatePrimaryLoading> {
Timer? _slowHintTimer;
bool _showSlowHint = false;
@override
void initState() {
super.initState();
_restartSlowHintTimer();
}
@override
void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible != oldWidget.visible) _restartSlowHintTimer();
}
void _restartSlowHintTimer() {
_slowHintTimer?.cancel();
_showSlowHint = false;
if (!widget.visible) return;
_slowHintTimer = Timer(widget.slowHintAfter, () {
if (mounted) setState(() => _showSlowHint = true);
});
}
@override
void dispose() {
_slowHintTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final status =
widget.statusText ??
(_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null);
return AnimatedOpacity(
opacity: widget.visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppProgressIndicator.large(),
AnimatedSwitcher(
duration: LoadableStateConsumer.animationDuration,
child: status == null
? const SizedBox.shrink()
: Padding(
key: ValueKey(status),
padding: const EdgeInsets.only(
top: 16,
left: 24,
right: 24,
),
child: Text(
status,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).hintColor,
),
),
),
),
],
),
),
);
}
}
@@ -35,10 +35,24 @@ abstract class LoadableHydratedBloc<
lastFetch: state.lastFetch,
reFetch: retry,
error: state.error,
statusText: state.statusText,
),
);
});
on<LoadingStatus<TState>>(
(event, emit) => emit(
LoadableState(
isLoading: state.isLoading,
data: innerState,
lastFetch: state.lastFetch,
reFetch: state.reFetch,
error: state.error,
statusText: event.statusText,
),
),
);
on<DataGathered<TState>>(
(event, emit) => emit(
LoadableState(
@@ -19,4 +19,12 @@ class Error<TState> extends LoadableHydratedBlocEvent<TState> {
class RefetchStarted<TState> extends LoadableHydratedBlocEvent<TState> {}
/// Sets the state's `statusText` — a transient progress note under the
/// primary loading spinner (e.g. "Erneuter Versuch (2 von 3) …"). Cleared
/// automatically by [DataGathered], [Error], [RefetchStarted] and [Reset].
class LoadingStatus<TState> extends LoadableHydratedBlocEvent<TState> {
final String? statusText;
LoadingStatus(this.statusText);
}
class Reset<TState> extends LoadableHydratedBlocEvent<TState> {}
@@ -96,6 +96,10 @@ class FilesBloc
);
add(Emit((s) => s.copyWith(listing: cached)));
},
onRetry: (next, max) {
if (isStale()) return;
add(LoadingStatus('Erneuter Versuch ($next von $max) …'));
},
onError: (e) => capturedError = e,
);
} catch (e) {
@@ -21,6 +21,7 @@ class FilesDataProvider {
void Function(ListFilesResponse)? onCacheData,
void Function(Object)? onError,
bool renew = false,
void Function(int nextAttempt, int maxAttempts)? onRetry,
}) => resolveFromCache<ListFilesResponse>(
(onUpdate, onError) => ListFilesCache(
path: path,
@@ -28,6 +29,7 @@ class FilesDataProvider {
onCacheData: onCacheData,
onError: onError,
renew: renew,
onRetry: onRetry,
),
onError: onError,
operationName: 'listFiles',