From 22b06b800a50f71688f751365e64940cae6e2461 Mon Sep 17 00:00:00 2001 From: hdbg Date: Sat, 20 Dec 2025 17:37:05 +0100 Subject: [PATCH] refactor(bootstrapper): UI redesign --- example/lib/main.dart | 8 +- lib/src/bootstrapper.dart | 311 +++++++++++++++++++++++------- lib/src/bootstrapper.freezed.dart | 186 ++++++++++++++++++ lib/src/bootstrapper.g.dart | 4 +- 4 files changed, 441 insertions(+), 68 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 485c0f0..bbb3264 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -52,9 +52,15 @@ void main() async { completer.future.then((_) { talker.info("Bootstrapper completed, launching app"); }); + + var ourTheme = commonTheme.copyWith( + scaffoldBackgroundColor: Colors.white, + colorScheme: ColorScheme.dark(surface: Colors.blueGrey), + ); + runApp( MaterialApp( - theme: ThemeData.dark(useMaterial3: true), + theme: ourTheme, home: ProviderScope( child: Bootstrapper( stages: [SimpleStage(), SimpleStage2()], diff --git a/lib/src/bootstrapper.dart b/lib/src/bootstrapper.dart index 6c049b1..937869d 100644 --- a/lib/src/bootstrapper.dart +++ b/lib/src/bootstrapper.dart @@ -1,7 +1,10 @@ // ignore_for_file: annotate_overrides import 'dart:async'; +import 'dart:ui'; + import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,22 +15,28 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'bootstrapper.g.dart'; part 'bootstrapper.freezed.dart'; +/// A single bootstrap stage. +/// Implementations should report progress/title updates via [StageController]. abstract class StageFactory { String get title; + /// Optional: if your stage can be skipped when already completed. Future get isAlreadyCompleted async => false; Future start(StageController controller); + void dispose() {} } +/// Bootstrap progress: either definite (0..1) or indefinite. @freezed sealed class Progress with _$Progress { const factory Progress.definite(double value) = PercentageProgress; const factory Progress.indefinite() = IndefiniteProgress; } -class StageState { +@freezed +class StageState with _$StageState { String title; Progress progress; @@ -39,20 +48,20 @@ typedef Stages = List; @riverpod class CurrentStage extends _$CurrentStage { @override - StageState? build() { - return null; - } + StageState? build() => null; - void set(StageState newState) { - state = newState; - } + void set(StageState newState) => state = newState; void setProgress(Progress progress) { - state?.progress = progress; + final s = state; + if (s == null) return; + state = s.copyWith(progress: progress); } void setTitle(String title) { - state?.title = title; + final s = state; + if (s == null) return; + state = s.copyWith(title: title); } } @@ -69,7 +78,9 @@ class StageController { void setIndefiniteProgress() { if (!_ref.mounted) return; - _ref.read(currentStageProvider.notifier).setProgress(Progress.indefinite()); + _ref + .read(currentStageProvider.notifier) + .setProgress(const Progress.indefinite()); } void updateTitle(String title) { @@ -82,29 +93,37 @@ class StageController { class StagesWorker extends _$StagesWorker { bool _hasStarted = false; - Future build() async { - return false; - } + @override + Future build() async => false; - void start(Stages stages) async { + Future start(Stages stages) async { if (_hasStarted) return; _hasStarted = true; - state = AsyncValue.loading(); + state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { for (final stage in stages) { talker.info("${stage.title} starting"); + // Optional skip. + final alreadyDone = await stage.isAlreadyCompleted; + if (alreadyDone) { + talker.info("${stage.title} skipped (already completed)"); + continue; + } + ref .read(currentStageProvider.notifier) .set( - StageState(title: stage.title, progress: Progress.indefinite()), + StageState( + title: stage.title, + progress: const Progress.indefinite(), + ), ); await stage.start(StageController(ref)); talker.info("${stage.title} completed"); } - return true; }); } @@ -112,32 +131,120 @@ class StagesWorker extends _$StagesWorker { class _DefiniteIndicator extends StatelessWidget { final double progress; // 0.0 to 1.0 - const _DefiniteIndicator({required this.progress}); @override Widget build(BuildContext context) { - final value = progress.clamp(0.0, 1.0); - return LayoutBuilder( - builder: (context, c) { - final side = (c.hasBoundedWidth && c.hasBoundedHeight) - ? (c.maxWidth < c.maxHeight ? c.maxWidth : c.maxHeight) - : 80.0; // sensible fallback if unconstrained + // Make it adaptive and square to prevent overflow in flexible layouts. + return AspectRatio( + aspectRatio: 1, + child: LayoutBuilder( + builder: (context, c) { + final side = c.maxWidth.isFinite ? c.maxWidth : 120.0; + final radius = side * 0.42; // padding around ring + final stroke = (side * 0.08).clamp(4.0, 10.0); - final radius = side / 2; + final value = progress.clamp(0.0, 1.0); + final formattedProgress = "${(value * 100).toStringAsFixed(0)}%"; - final formattedProgress = "${(progress * 100).toStringAsFixed(0)}%"; - return CircularPercentIndicator( - radius: radius * 2, - lineWidth: 5.0, - percent: value, - center: Text(formattedProgress), - progressColor: Theme.of(context).colorScheme.secondary, - ); - }, + return Center( + child: CircularPercentIndicator( + radius: radius, + lineWidth: stroke, + percent: value, + center: Text( + formattedProgress, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + circularStrokeCap: CircularStrokeCap.round, + backgroundColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.12), + progressColor: Theme.of(context).colorScheme.secondary, + ), + ); + }, + ), ); + } +} - // return CircularProgressIndicator(value: value); +class _IndefiniteIndicator extends StatelessWidget { + const _IndefiniteIndicator(); + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 1, + child: LayoutBuilder( + builder: (context, c) { + final side = c.maxWidth.isFinite ? c.maxWidth : 120.0; + final size = side * 0.55; + return Center( + child: SizedBox( + width: size, + height: size, + child: SpinKitRing( + color: Theme.of(context).colorScheme.secondary, + lineWidth: (side * 0.06).clamp(3.0, 8.0), + ), + ), + ); + }, + ), + ); + } +} + +class _FrostCard extends StatelessWidget { + const _FrostCard({required this.child}); + final Widget child; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ClipRRect( + borderRadius: BorderRadius.circular(22), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), + child: DecoratedBox( + decoration: BoxDecoration( + color: cs.surface.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: cs.onSurface.withValues(alpha: 0.10), + width: 1, + ), + ), + child: child, + ), + ), + ); + } +} + +class _BackgroundGlow extends StatelessWidget { + const _BackgroundGlow(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: const Alignment(0, -0.55), + radius: 1.25, + colors: [ + cs.primary.withValues(alpha: 0.18), + cs.secondary.withValues(alpha: 0.10), + Colors.black, + ], + stops: const [0.0, 0.45, 1.0], + ), + ), + ); } } @@ -146,31 +253,60 @@ class _BootstrapFooter extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(currentStageProvider); - if (state == null) return Container(); + final stage = ref.watch(currentStageProvider); - final progressIndicator = state.progress.when( + final title = stage?.title ?? "Preparing…"; + final progress = stage?.progress ?? const Progress.indefinite(); + + final indicator = progress.when( definite: (definite) => _DefiniteIndicator(progress: definite), - indefinite: () => SpinKitCubeGrid(color: Colors.white), + indefinite: () => const _IndefiniteIndicator(), ); - return Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.max, - children: [ - Flexible( - flex: 1, - child: Text( - state.title, - style: Theme.of(context).textTheme.titleLarge, + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: _FrostCard( + child: Padding( + padding: const EdgeInsets.all(22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: Text( + title, + key: ValueKey(title), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ), + const SizedBox(height: 12), + Text( + "Please don’t close the app", + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.72), + ), + ), + const SizedBox(height: 18), + SizedBox(height: 180, child: indicator), + ], ), ), - Flexible(flex: 1, child: progressIndicator), - ], + ), ); } } +/// Bootstrapper UI + lifecycle glue. +/// +/// - Runs [StagesWorker.start] exactly once (using hooks). +/// - Uses `ref.listen` to complete [onCompleted] without rebuilding the logo. +/// - Watches only [CurrentStage] for UI updates. class Bootstrapper extends HookConsumerWidget { final Stages stages; final Completer onCompleted; @@ -183,26 +319,71 @@ class Bootstrapper extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - Future(() async => ref.read(stagesWorkerProvider.notifier).start(stages)); - final work = ref.watch(stagesWorkerProvider); + useEffect(() { + Future.microtask( + () => ref.read(stagesWorkerProvider.notifier).start(stages), + ); - if (!onCompleted.isCompleted) { - if (work.hasError) onCompleted.completeError(work.error!); - if (work.hasValue && work.value!) onCompleted.complete(); - } + return null; + }, const []); + ref.listen>(stagesWorkerProvider, (_, next) { + if (onCompleted.isCompleted) return; - return Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.center, + next.whenOrNull( + data: (ok) { + if (ok) onCompleted.complete(); + }, + error: (e, st) => onCompleted.completeError(e, st), + ); + }); + + return Stack( children: [ - Flexible( - flex: 2, - child: Center(child: Loader.playing(flavour: LoaderFlavour.big)), + const _BackgroundGlow(), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), + child: Column( + children: const [ + // Keep this as a const child so it doesn't restart animations + // when the footer rebuilds. + _LogoHeader(), + SizedBox(height: 24), + Expanded(child: Center(child: _BootstrapFooter())), + SizedBox(height: 16), + _BottomCaption(), + ], + ), + ), ), - Flexible(flex: 1, child: Container()), - Flexible(flex: 1, child: _BootstrapFooter()), - Flexible(flex: 1, child: Container()), ], ); } } + +class _LogoHeader extends StatelessWidget { + const _LogoHeader(); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 160, + child: Center(child: Loader.playing(flavour: LoaderFlavour.big)), + ); + } +} + +class _BottomCaption extends StatelessWidget { + const _BottomCaption(); + + @override + Widget build(BuildContext context) { + return Text( + "Initializing secure components…", + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.55), + letterSpacing: 0.2, + ), + ); + } +} diff --git a/lib/src/bootstrapper.freezed.dart b/lib/src/bootstrapper.freezed.dart index 1b81ea6..b655f71 100644 --- a/lib/src/bootstrapper.freezed.dart +++ b/lib/src/bootstrapper.freezed.dart @@ -269,4 +269,190 @@ String toString() { +/// @nodoc +mixin _$StageState { + + String get title; set title(String value); Progress get progress; set progress(Progress value); +/// Create a copy of StageState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$StageStateCopyWith get copyWith => _$StageStateCopyWithImpl(this as StageState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StageState&&(identical(other.title, title) || other.title == title)&&(identical(other.progress, progress) || other.progress == progress)); +} + + +@override +int get hashCode => Object.hash(runtimeType,title,progress); + +@override +String toString() { + return 'StageState(title: $title, progress: $progress)'; +} + + +} + +/// @nodoc +abstract mixin class $StageStateCopyWith<$Res> { + factory $StageStateCopyWith(StageState value, $Res Function(StageState) _then) = _$StageStateCopyWithImpl; +@useResult +$Res call({ + String title, Progress progress +}); + + + + +} +/// @nodoc +class _$StageStateCopyWithImpl<$Res> + implements $StageStateCopyWith<$Res> { + _$StageStateCopyWithImpl(this._self, this._then); + + final StageState _self; + final $Res Function(StageState) _then; + +/// Create a copy of StageState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? title = null,Object? progress = null,}) { + return _then(StageState( +title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,progress: null == progress ? _self.progress : progress // ignore: cast_nullable_to_non_nullable +as Progress, + )); +} + +} + + +/// Adds pattern-matching-related methods to [StageState]. +extension StageStatePatterns on StageState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(){ +final _that = this; +switch (_that) { +case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(){ +final _that = this; +switch (_that) { +case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({required TResult orElse(),}) {final _that = this; +switch (_that) { +case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when() {final _that = this; +switch (_that) { +case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull() {final _that = this; +switch (_that) { +case _: + return null; + +} +} + +} + // dart format on diff --git a/lib/src/bootstrapper.g.dart b/lib/src/bootstrapper.g.dart index 66ad3ac..caea471 100644 --- a/lib/src/bootstrapper.g.dart +++ b/lib/src/bootstrapper.g.dart @@ -41,7 +41,7 @@ final class CurrentStageProvider } } -String _$currentStageHash() => r'9f294430d9b3fb21fd51f97163443b56bd152855'; +String _$currentStageHash() => r'7da6320c5c830e9438bf752dc990297dd410d25a'; abstract class _$CurrentStage extends $Notifier { StageState? build(); @@ -86,7 +86,7 @@ final class StagesWorkerProvider StagesWorker create() => StagesWorker(); } -String _$stagesWorkerHash() => r'68f4d51d98fa27e17496e24e4b8eeeca1815f323'; +String _$stagesWorkerHash() => r'44d8ae0ea8910ee05343c96e4056226e293afd67'; abstract class _$StagesWorker extends $AsyncNotifier { FutureOr build();