refactor(bootstrapper): UI redesign

This commit is contained in:
hdbg
2025-12-20 17:37:05 +01:00
parent d7ec54d8ca
commit 22b06b800a
4 changed files with 441 additions and 68 deletions

View File

@@ -52,9 +52,15 @@ void main() async {
completer.future.then((_) { completer.future.then((_) {
talker.info("Bootstrapper completed, launching app"); talker.info("Bootstrapper completed, launching app");
}); });
var ourTheme = commonTheme.copyWith(
scaffoldBackgroundColor: Colors.white,
colorScheme: ColorScheme.dark(surface: Colors.blueGrey),
);
runApp( runApp(
MaterialApp( MaterialApp(
theme: ThemeData.dark(useMaterial3: true), theme: ourTheme,
home: ProviderScope( home: ProviderScope(
child: Bootstrapper( child: Bootstrapper(
stages: [SimpleStage(), SimpleStage2()], stages: [SimpleStage(), SimpleStage2()],

View File

@@ -1,7 +1,10 @@
// ignore_for_file: annotate_overrides // ignore_for_file: annotate_overrides
import 'dart:async'; import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.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.g.dart';
part 'bootstrapper.freezed.dart'; part 'bootstrapper.freezed.dart';
/// A single bootstrap stage.
/// Implementations should report progress/title updates via [StageController].
abstract class StageFactory { abstract class StageFactory {
String get title; String get title;
/// Optional: if your stage can be skipped when already completed.
Future<bool> get isAlreadyCompleted async => false; Future<bool> get isAlreadyCompleted async => false;
Future<void> start(StageController controller); Future<void> start(StageController controller);
void dispose() {} void dispose() {}
} }
/// Bootstrap progress: either definite (0..1) or indefinite.
@freezed @freezed
sealed class Progress with _$Progress { sealed class Progress with _$Progress {
const factory Progress.definite(double value) = PercentageProgress; const factory Progress.definite(double value) = PercentageProgress;
const factory Progress.indefinite() = IndefiniteProgress; const factory Progress.indefinite() = IndefiniteProgress;
} }
class StageState { @freezed
class StageState with _$StageState {
String title; String title;
Progress progress; Progress progress;
@@ -39,20 +48,20 @@ typedef Stages = List<StageFactory>;
@riverpod @riverpod
class CurrentStage extends _$CurrentStage { class CurrentStage extends _$CurrentStage {
@override @override
StageState? build() { StageState? build() => null;
return null;
}
void set(StageState newState) { void set(StageState newState) => state = newState;
state = newState;
}
void setProgress(Progress progress) { void setProgress(Progress progress) {
state?.progress = progress; final s = state;
if (s == null) return;
state = s.copyWith(progress: progress);
} }
void setTitle(String title) { 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() { void setIndefiniteProgress() {
if (!_ref.mounted) return; if (!_ref.mounted) return;
_ref.read(currentStageProvider.notifier).setProgress(Progress.indefinite()); _ref
.read(currentStageProvider.notifier)
.setProgress(const Progress.indefinite());
} }
void updateTitle(String title) { void updateTitle(String title) {
@@ -82,29 +93,37 @@ class StageController {
class StagesWorker extends _$StagesWorker { class StagesWorker extends _$StagesWorker {
bool _hasStarted = false; bool _hasStarted = false;
Future<bool> build() async { @override
return false; Future<bool> build() async => false;
}
void start(Stages stages) async { Future<void> start(Stages stages) async {
if (_hasStarted) return; if (_hasStarted) return;
_hasStarted = true; _hasStarted = true;
state = AsyncValue.loading(); state = const AsyncValue.loading();
state = await AsyncValue.guard(() async { state = await AsyncValue.guard(() async {
for (final stage in stages) { for (final stage in stages) {
talker.info("${stage.title} starting"); talker.info("${stage.title} starting");
// Optional skip.
final alreadyDone = await stage.isAlreadyCompleted;
if (alreadyDone) {
talker.info("${stage.title} skipped (already completed)");
continue;
}
ref ref
.read(currentStageProvider.notifier) .read(currentStageProvider.notifier)
.set( .set(
StageState(title: stage.title, progress: Progress.indefinite()), StageState(
title: stage.title,
progress: const Progress.indefinite(),
),
); );
await stage.start(StageController(ref)); await stage.start(StageController(ref));
talker.info("${stage.title} completed"); talker.info("${stage.title} completed");
} }
return true; return true;
}); });
} }
@@ -112,32 +131,120 @@ class StagesWorker extends _$StagesWorker {
class _DefiniteIndicator extends StatelessWidget { class _DefiniteIndicator extends StatelessWidget {
final double progress; // 0.0 to 1.0 final double progress; // 0.0 to 1.0
const _DefiniteIndicator({required this.progress}); const _DefiniteIndicator({required this.progress});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final value = progress.clamp(0.0, 1.0); // Make it adaptive and square to prevent overflow in flexible layouts.
return LayoutBuilder( return AspectRatio(
aspectRatio: 1,
child: LayoutBuilder(
builder: (context, c) { builder: (context, c) {
final side = (c.hasBoundedWidth && c.hasBoundedHeight) final side = c.maxWidth.isFinite ? c.maxWidth : 120.0;
? (c.maxWidth < c.maxHeight ? c.maxWidth : c.maxHeight) final radius = side * 0.42; // padding around ring
: 80.0; // sensible fallback if unconstrained 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 Center(
return CircularPercentIndicator( child: CircularPercentIndicator(
radius: radius * 2, radius: radius,
lineWidth: 5.0, lineWidth: stroke,
percent: value, percent: value,
center: Text(formattedProgress), 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, 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(currentStageProvider); final stage = ref.watch(currentStageProvider);
if (state == null) return Container();
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), definite: (definite) => _DefiniteIndicator(progress: definite),
indefinite: () => SpinKitCubeGrid(color: Colors.white), indefinite: () => const _IndefiniteIndicator(),
); );
return Column( return ConstrainedBox(
mainAxisAlignment: MainAxisAlignment.spaceBetween, constraints: const BoxConstraints(maxWidth: 520),
mainAxisSize: MainAxisSize.max, child: _FrostCard(
child: Padding(
padding: const EdgeInsets.all(22),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Flexible( AnimatedSwitcher(
flex: 1, duration: const Duration(milliseconds: 250),
child: Text( child: Text(
state.title, title,
style: Theme.of(context).textTheme.titleLarge, key: ValueKey(title),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
), ),
), ),
Flexible(flex: 1, child: progressIndicator), ),
const SizedBox(height: 12),
Text(
"Please dont 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),
], ],
),
),
),
); );
} }
} }
/// 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 { class Bootstrapper extends HookConsumerWidget {
final Stages stages; final Stages stages;
final Completer onCompleted; final Completer onCompleted;
@@ -183,26 +319,71 @@ class Bootstrapper extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
Future(() async => ref.read(stagesWorkerProvider.notifier).start(stages)); useEffect(() {
final work = ref.watch(stagesWorkerProvider); Future.microtask(
() => ref.read(stagesWorkerProvider.notifier).start(stages),
);
if (!onCompleted.isCompleted) { return null;
if (work.hasError) onCompleted.completeError(work.error!); }, const []);
if (work.hasValue && work.value!) onCompleted.complete(); ref.listen<AsyncValue<bool>>(stagesWorkerProvider, (_, next) {
} if (onCompleted.isCompleted) return;
return Column( next.whenOrNull(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, data: (ok) {
crossAxisAlignment: CrossAxisAlignment.center, if (ok) onCompleted.complete();
},
error: (e, st) => onCompleted.completeError(e, st),
);
});
return Stack(
children: [ children: [
Flexible( const _BackgroundGlow(),
flex: 2, SafeArea(
child: Center(child: Loader.playing(flavour: LoaderFlavour.big)), 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,
),
);
}
}

View File

@@ -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<StageState> get copyWith => _$StageStateCopyWithImpl<StageState>(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<TResult extends Object?>({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<TResult extends Object?>(){
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<TResult extends Object?>(){
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<TResult extends Object?>({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<TResult extends Object?>() {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<TResult extends Object?>() {final _that = this;
switch (_that) {
case _:
return null;
}
}
}
// dart format on // dart format on

View File

@@ -41,7 +41,7 @@ final class CurrentStageProvider
} }
} }
String _$currentStageHash() => r'9f294430d9b3fb21fd51f97163443b56bd152855'; String _$currentStageHash() => r'7da6320c5c830e9438bf752dc990297dd410d25a';
abstract class _$CurrentStage extends $Notifier<StageState?> { abstract class _$CurrentStage extends $Notifier<StageState?> {
StageState? build(); StageState? build();
@@ -86,7 +86,7 @@ final class StagesWorkerProvider
StagesWorker create() => StagesWorker(); StagesWorker create() => StagesWorker();
} }
String _$stagesWorkerHash() => r'68f4d51d98fa27e17496e24e4b8eeeca1815f323'; String _$stagesWorkerHash() => r'44d8ae0ea8910ee05343c96e4056226e293afd67';
abstract class _$StagesWorker extends $AsyncNotifier<bool> { abstract class _$StagesWorker extends $AsyncNotifier<bool> {
FutureOr<bool> build(); FutureOr<bool> build();