390 lines
10 KiB
Dart
390 lines
10 KiB
Dart
// 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 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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
),
|
||
);
|
||
}
|
||
}
|