claude refactorings, flutter best practices, platform dependent changes, general cleanup

This commit is contained in:
2026-05-06 11:58:50 +02:00
parent 4b1d4379a0
commit 72ebe6f7e7
278 changed files with 1804 additions and 1041 deletions
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'loadable_state_consumer.dart';
class LoadableStateBackgroundLoading extends StatelessWidget {
final bool visible;
const LoadableStateBackgroundLoading({required this.visible, super.key});
@override
Widget build(BuildContext context) => AnimatedSwitcher(
duration: LoadableStateConsumer.animationDuration,
transitionBuilder: (Widget child, Animation<double> animation) => SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, -1.0),
end: Offset.zero,
).animate(animation),
child: child,
),
child: visible ? const LinearProgressIndicator() : const SizedBox.shrink(),
);
}
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../widget/conditional_wrapper.dart';
import '../../utility_widgets/bloc_module.dart';
import '../../utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../bloc/loadable_state_bloc.dart';
import '../bloc/loadable_state_state.dart';
import '../loadable_state.dart';
import 'loadable_state_background_loading.dart';
import 'loadable_state_error_bar.dart';
import 'loadable_state_error_screen.dart';
import 'loadable_state_primary_loading.dart';
class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<TState>, LoadableState<TState>>, TState> extends StatelessWidget {
final Widget Function(TState state, bool loading) child;
final void Function(TState state)? onLoad;
final bool wrapWithScrollView;
/// Optional predicate for callers whose [TState] always contains a non-null
/// envelope but where actual content (e.g. a nested response) is loaded
/// lazily. When provided, this overrides the default `data != null` check
/// so primary loading / error screens / content visibility correctly reflect
/// whether the inner content is ready.
final bool Function(TState state)? isReady;
const LoadableStateConsumer({
required this.child,
this.onLoad,
this.wrapWithScrollView = false,
this.isReady,
super.key,
});
static Duration animationDuration = const Duration(milliseconds: 200);
@override
Widget build(BuildContext context) {
var loadableState = context.watch<TController>().state;
final loadedData = loadableState.data;
if(!loadableState.isLoading && onLoad != null && loadedData is TState) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => onLoad!(loadedData));
}
final typedData = loadedData is TState ? loadedData : null;
final hasContent = typedData != null && (isReady?.call(typedData) ?? loadableState.showContent());
final hasError = loadableState.error != null;
final isLoading = loadableState.isLoading;
final showPrimaryLoading = isLoading && !hasContent;
final showBackgroundLoading = isLoading && hasContent;
final showError = hasError && !hasContent;
final showErrorBar = hasError && hasContent;
var childWidget = ConditionalWrapper(
condition: loadableState.reFetch != null,
wrapper: (child) => RefreshIndicator(
onRefresh: () {
if(loadableState.reFetch != null) loadableState.reFetch!();
return Future.value();
},
child: ConditionalWrapper(
condition: wrapWithScrollView,
wrapper: (child) => SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: child
),
child: child,
)
),
child: SizedBox(
height: MediaQuery.of(context).size.height,
child: hasContent
? child(typedData as TState, isLoading)
: const SizedBox.shrink(),
),
);
return BlocModule<LoadableStateBloc, LoadableStateState>(
create: (context) => LoadableStateBloc(),
child: (context, bloc, state) {
bloc.reFetch = loadableState.reFetch;
return Column(
children: [
LoadableStateErrorBar(
visible: showErrorBar,
hasContent: hasContent,
message: loadableState.error?.message,
technicalDetails: loadableState.error?.technicalDetails,
lastUpdated: loadableState.lastFetch,
),
Expanded(
child: Stack(
children: [
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
LoadableStateBackgroundLoading(visible: showBackgroundLoading),
LoadableStateErrorScreen(
visible: showError,
message: loadableState.error?.message,
technicalDetails: loadableState.error?.technicalDetails,
),
AnimatedOpacity(
opacity: hasContent ? 1.0 : 0.0,
duration: animationDuration,
curve: Curves.easeInOut,
child: childWidget,
),
],
),
)
],
);
}
);
}
}
@@ -0,0 +1,115 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../widget/info_dialog.dart';
import '../bloc/loadable_state_bloc.dart';
class LoadableStateErrorBar extends StatelessWidget {
final bool visible;
final bool hasContent;
final String? message;
final String? technicalDetails;
final int? lastUpdated;
const LoadableStateErrorBar({
required this.visible,
this.hasContent = false,
this.message,
this.technicalDetails,
this.lastUpdated,
super.key,
});
final Duration animationDuration = const Duration(milliseconds: 200);
@override
Widget build(BuildContext context) {
final bloc = context.watch<LoadableStateBloc>();
final isOfflineWithCache = hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected();
final shouldShow = visible || isOfflineWithCache;
return AnimatedSize(
duration: animationDuration,
child: AnimatedSwitcher(
duration: animationDuration,
transitionBuilder: (Widget child, Animation<double> animation) => SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, -1.0),
end: Offset.zero,
).animate(animation),
child: child,
),
child: Visibility(
key: Key(shouldShow.hashCode.toString()),
visible: shouldShow,
replacement: const SizedBox(width: double.infinity),
child: Builder(
builder: (context) {
var bloc = context.watch<LoadableStateBloc>();
return InkWell(
onTap: () {
if(!bloc.isConnected()) return;
final body = [
if (message != null && message!.isNotEmpty) message!,
if (technicalDetails != null && technicalDetails!.isNotEmpty) technicalDetails!,
].join('\n\n');
if (body.isEmpty) return;
InfoDialog.show(context, body);
},
child: Container(
height: 20,
decoration: BoxDecoration(
color: bloc.connectionColor(context),
),
child: LoadableStateErrorBarText(lastUpdated: lastUpdated),
),
);
},
)
)
),
);
}
}
class LoadableStateErrorBarText extends StatefulWidget {
final int? lastUpdated;
const LoadableStateErrorBarText({required this.lastUpdated, super.key});
@override
State<LoadableStateErrorBarText> createState() => _LoadableStateErrorBarTextState();
}
class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
late Timer _rebuildTimer;
@override
void initState() {
_rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) => setState(() {}));
super.initState();
}
@override
Widget build(BuildContext context) {
var bloc = context.watch<LoadableStateBloc>();
final foreground = bloc.connectionForegroundColor(context);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(bloc.connectionIcon(), size: 14, color: foreground),
const SizedBox(width: 10),
Text(
bloc.connectionText(lastUpdated: widget.lastUpdated),
style: TextStyle(fontSize: 12, color: foreground),
),
],
);
}
@override
void dispose() {
_rebuildTimer.cancel();
super.dispose();
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../widget/info_dialog.dart';
import '../bloc/loadable_state_bloc.dart';
import 'loadable_state_consumer.dart';
class LoadableStateErrorScreen extends StatelessWidget {
final bool visible;
final String? message;
final String? technicalDetails;
const LoadableStateErrorScreen({
required this.visible,
this.message,
this.technicalDetails,
super.key,
});
@override
Widget build(BuildContext context) {
final bloc = context.watch<LoadableStateBloc>();
final isOffline = bloc.connectivityStatusKnown() && !bloc.isConnected();
final headline = isOffline ? bloc.connectionText() : (message ?? bloc.connectionText());
return AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
child: !visible ? null : Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(bloc.connectionIcon(), size: 40),
const SizedBox(height: 12),
Text(
headline,
style: const TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
if (!isOffline && message != null && message != headline) ...[
const SizedBox(height: 8),
Text(
message!,
style: TextStyle(color: Theme.of(context).hintColor, fontSize: 14),
textAlign: TextAlign.center,
),
],
if (bloc.allowRetry()) ...[
const SizedBox(height: 16),
TextButton(
onPressed: () => bloc.reFetch!(),
child: const Text('Erneut versuchen'),
),
],
if (technicalDetails != null) ...[
const SizedBox(height: 4),
TextButton(
onPressed: () => InfoDialog.show(context, technicalDetails!),
child: const Text('Details anzeigen'),
),
],
],
),
),
),
);
}
}
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import '../../../../../widget/app_progress_indicator.dart';
import 'loadable_state_consumer.dart';
class LoadableStatePrimaryLoading extends StatelessWidget {
final bool visible;
const LoadableStatePrimaryLoading({required this.visible, super.key});
@override
Widget build(BuildContext context) => AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
child: const Center(child: AppProgressIndicator.large()),
);
}