claude refactorings, flutter best practices, platform dependent changes, general cleanup
This commit is contained in:
+21
@@ -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()),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user