Files
markettakers-flutter/lib/src/bootstrapper.dart
2025-12-20 17:45:38 +01:00

390 lines
10 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
import 'package:mtcore/markettakers.dart';
import 'package:percent_indicator/circular_percent_indicator.dart';
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<bool> get isAlreadyCompleted async => false;
Future<void> 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;
}
@freezed
class StageState with _$StageState {
String title;
Progress progress;
StageState({required this.title, required this.progress});
}
typedef Stages = List<StageFactory>;
@riverpod
class CurrentStage extends _$CurrentStage {
@override
StageState? build() => null;
void set(StageState newState) => state = newState;
void setProgress(Progress progress) {
final s = state;
if (s == null) return;
state = s.copyWith(progress: progress);
}
void setTitle(String title) {
final s = state;
if (s == null) return;
state = s.copyWith(title: title);
}
}
class StageController {
final Ref _ref;
StageController(this._ref);
void setDefiniteProgress(double value) {
if (!_ref.mounted) return;
_ref
.read(currentStageProvider.notifier)
.setProgress(Progress.definite(value));
}
void setIndefiniteProgress() {
if (!_ref.mounted) return;
_ref
.read(currentStageProvider.notifier)
.setProgress(const Progress.indefinite());
}
void updateTitle(String title) {
if (!_ref.mounted) return;
_ref.read(currentStageProvider.notifier).setTitle(title);
}
}
@riverpod
class StagesWorker extends _$StagesWorker {
bool _hasStarted = false;
@override
Future<bool> build() async => false;
Future<void> start(Stages stages) async {
if (_hasStarted) return;
_hasStarted = true;
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: const Progress.indefinite(),
),
);
await stage.start(StageController(ref));
talker.info("${stage.title} completed");
}
return true;
});
}
}
class _DefiniteIndicator extends StatelessWidget {
final double progress; // 0.0 to 1.0
const _DefiniteIndicator({required this.progress});
@override
Widget build(BuildContext context) {
// 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 value = progress.clamp(0.0, 1.0);
final formattedProgress = "${(value * 100).toStringAsFixed(0)}%";
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,
),
);
},
),
);
}
}
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],
),
),
);
}
}
class _BootstrapFooter extends ConsumerWidget {
const _BootstrapFooter();
@override
Widget build(BuildContext context, WidgetRef ref) {
final stage = ref.watch(currentStageProvider);
final title = stage?.title ?? "Preparing…";
final progress = stage?.progress ?? const Progress.indefinite();
final indicator = progress.when(
definite: (definite) => _DefiniteIndicator(progress: definite),
indefinite: () => const _IndefiniteIndicator(),
);
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 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 {
final Stages stages;
final Completer onCompleted;
const Bootstrapper({
super.key,
required this.stages,
required this.onCompleted,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
useEffect(() {
Future.microtask(
() => ref.read(stagesWorkerProvider.notifier).start(stages),
);
return null;
}, const []);
ref.listen<AsyncValue<bool>>(stagesWorkerProvider, (_, next) {
if (onCompleted.isCompleted) return;
next.whenOrNull(
data: (ok) {
if (ok) onCompleted.complete();
},
error: (e, st) => onCompleted.completeError(e, st),
);
});
return Stack(
children: [
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(),
],
),
),
),
],
);
}
}
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,
),
);
}
}