503 lines
15 KiB
Dart
503 lines
15 KiB
Dart
import 'dart:async';
|
||
import 'dart:math';
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../models/app_version.dart';
|
||
import '../widgets/update_dialog.dart';
|
||
import '../theme/m3_motion.dart';
|
||
|
||
/// Simple binary rain animation with a label for the update check splash.
|
||
enum UpdateCheckStatus { checking, noInternet, upToDate, updateFound, error }
|
||
|
||
/// Simple binary rain animation with a label for the update check splash.
|
||
class UpdateCheckingScreen extends StatefulWidget {
|
||
const UpdateCheckingScreen({
|
||
super.key,
|
||
this.onCompleted,
|
||
this.checkForUpdates,
|
||
});
|
||
|
||
/// Called once the check completes (success, no internet, etc.).
|
||
final void Function(AppUpdateInfo? info)? onCompleted;
|
||
|
||
/// Optional update check implementation.
|
||
///
|
||
/// If null, the screen will simulate a check and mark it as up-to-date.
|
||
final Future<AppUpdateInfo> Function()? checkForUpdates;
|
||
|
||
@override
|
||
State<UpdateCheckingScreen> createState() => _UpdateCheckingScreenState();
|
||
}
|
||
|
||
class _UpdateCheckingScreenState extends State<UpdateCheckingScreen>
|
||
with SingleTickerProviderStateMixin {
|
||
static const int cols = 14;
|
||
static const int minDrops = 8;
|
||
static const double minSpeed = 2.0;
|
||
static const double maxSpeed = 4.5;
|
||
static const double rainHeight = 150.0;
|
||
static const double labelZoneHeight = 32.0;
|
||
static const double rainContainerHeight = rainHeight + labelZoneHeight;
|
||
static const double cloudWidth = 220.0;
|
||
|
||
final List<_Drop> _drops = [];
|
||
final List<_Spark> _sparks = [];
|
||
Timer? _timer;
|
||
final Random _rng = Random();
|
||
|
||
late final AnimationController _cloudController;
|
||
double _labelGlow = 0.0; // 0..1
|
||
UpdateCheckStatus _status = UpdateCheckStatus.checking;
|
||
bool _hasShownUpdateDialog = false;
|
||
AppUpdateInfo? _info;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_cloudController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(seconds: 3),
|
||
)..repeat(reverse: true);
|
||
|
||
_timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
||
_tick();
|
||
});
|
||
|
||
_runUpdateCheck();
|
||
}
|
||
|
||
String get _statusLabel {
|
||
switch (_status) {
|
||
case UpdateCheckStatus.checking:
|
||
return 'Checking for updates...';
|
||
case UpdateCheckStatus.noInternet:
|
||
return 'No internet connection';
|
||
case UpdateCheckStatus.upToDate:
|
||
if ((_info?.currentBuildNumber.isNotEmpty) == true) {
|
||
return 'Up to date (${_info!.currentBuildNumber})';
|
||
}
|
||
return 'App is already up to date';
|
||
case UpdateCheckStatus.updateFound:
|
||
final current = _info?.currentBuildNumber ?? '';
|
||
final latest = _info?.latestVersion?.versionCode ?? '';
|
||
if (current.isNotEmpty && latest.isNotEmpty) {
|
||
return 'Update found ($current → $latest)';
|
||
}
|
||
return 'Update found!';
|
||
case UpdateCheckStatus.error:
|
||
return 'Unable to check updates';
|
||
}
|
||
}
|
||
|
||
Future<void> _runUpdateCheck() async {
|
||
// Allow the rain animation to play for a short time before completing.
|
||
await Future.delayed(const Duration(milliseconds: 900));
|
||
|
||
try {
|
||
final info =
|
||
await (widget.checkForUpdates?.call() ??
|
||
Future.value(
|
||
AppUpdateInfo(
|
||
currentBuildNumber: '',
|
||
latestVersion: null,
|
||
isUpdateAvailable: false,
|
||
isForceUpdate: false,
|
||
),
|
||
));
|
||
if (!mounted) return;
|
||
|
||
setState(() {
|
||
_info = info;
|
||
_status = info.isUpdateAvailable
|
||
? UpdateCheckStatus.updateFound
|
||
: UpdateCheckStatus.upToDate;
|
||
});
|
||
|
||
// If an update is found, show the update dialog from here so it is
|
||
// guaranteed to appear even if the parent wrapper isn't ready.
|
||
if (info.isUpdateAvailable && !_hasShownUpdateDialog) {
|
||
_hasShownUpdateDialog = true;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||
if (!mounted) return;
|
||
try {
|
||
await _showM3Dialog(
|
||
context: context,
|
||
barrierDismissible: !info.isForceUpdate,
|
||
builder: (_) => UpdateDialog(info: info),
|
||
);
|
||
} catch (_) {}
|
||
});
|
||
}
|
||
|
||
// Let the UI show the final status briefly before continuing.
|
||
await Future.delayed(const Duration(milliseconds: 400));
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
widget.onCompleted?.call(info);
|
||
});
|
||
} catch (_) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_status = UpdateCheckStatus.error;
|
||
});
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
widget.onCompleted?.call(null);
|
||
});
|
||
}
|
||
|
||
// Brief pause to let user read the final state.
|
||
await Future.delayed(const Duration(milliseconds: 850));
|
||
if (!mounted) return;
|
||
|
||
// If no completion callback is provided, try to close this screen.
|
||
if (widget.onCompleted == null) {
|
||
Navigator.of(context).maybePop();
|
||
}
|
||
}
|
||
|
||
Future<T?> _showM3Dialog<T>({
|
||
required BuildContext context,
|
||
required WidgetBuilder builder,
|
||
bool barrierDismissible = true,
|
||
}) {
|
||
return showGeneralDialog<T>(
|
||
context: context,
|
||
barrierDismissible: barrierDismissible,
|
||
barrierColor: Colors.black54,
|
||
transitionDuration: M3Motion.standard,
|
||
pageBuilder: (context, animation, secondaryAnimation) => builder(context),
|
||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||
// Use a Material-3–style fade-through transition.
|
||
return _M3DialogFadeThrough(
|
||
animation: animation,
|
||
secondaryAnimation: secondaryAnimation,
|
||
child: child,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _tick() {
|
||
setState(() {
|
||
// Keep at least a couple drops near the label so the rain looks like it
|
||
// is actually hitting the text.
|
||
final labelTop = rainHeight - 8;
|
||
final dropsNearLabel = _drops.where((d) => d.y > labelTop - 14).length;
|
||
final neededDrops = max(0, 2 - dropsNearLabel);
|
||
for (var i = 0; i < neededDrops; i++) {
|
||
_drops.add(
|
||
_Drop(
|
||
col: _rng.nextInt(cols),
|
||
y: labelTop - 10 - _rng.nextDouble() * 8,
|
||
speed: _rng.nextDouble() * (maxSpeed - minSpeed) + minSpeed,
|
||
),
|
||
);
|
||
}
|
||
|
||
// Add occasional fresh drops from the top.
|
||
if (_drops.length < minDrops || _rng.nextDouble() < 0.2) {
|
||
_drops.add(
|
||
_Drop(
|
||
col: _rng.nextInt(cols),
|
||
y: 0.0,
|
||
speed: _rng.nextDouble() * (maxSpeed - minSpeed) + minSpeed,
|
||
),
|
||
);
|
||
}
|
||
|
||
// Remove drops once they reach the label area so they don't fall below.
|
||
_drops.removeWhere((d) => d.y > labelTop);
|
||
|
||
// Update drops and trigger label glow when they reach the label area.
|
||
var glowTriggered = false;
|
||
for (final d in _drops) {
|
||
d.y += d.speed;
|
||
if (!glowTriggered && d.y > labelTop - 4) {
|
||
glowTriggered = true;
|
||
}
|
||
}
|
||
|
||
// If glow triggered, emit sparkles around the label.
|
||
if (glowTriggered) {
|
||
_labelGlow = 1.0;
|
||
for (var i = 0; i < 3; i++) {
|
||
_sparks.add(
|
||
_Spark(
|
||
x: _rng.nextDouble() * cloudWidth,
|
||
y: rainHeight + 12,
|
||
size: _rng.nextDouble() * 4 + 2,
|
||
life: 12,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// decay glow + update sparks
|
||
_labelGlow = (_labelGlow - 0.06).clamp(0.0, 1.0);
|
||
_sparks.removeWhere((s) => s.life <= 0);
|
||
for (final s in _sparks) {
|
||
s.life -= 1;
|
||
s.y -= 0.8;
|
||
s.alpha = s.life / 12.0;
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_timer?.cancel();
|
||
_cloudController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return Scaffold(
|
||
backgroundColor: cs.surface,
|
||
body: SafeArea(
|
||
child: Center(
|
||
child: SizedBox(
|
||
width: cloudWidth,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
SizedBox(
|
||
height: 120,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
Positioned(
|
||
top: 0,
|
||
child: Hero(
|
||
tag: 'tasq-logo',
|
||
child: Image.asset(
|
||
'assets/tasq_ico.png',
|
||
height: 74,
|
||
width: 74,
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
bottom: 0,
|
||
child: AnimatedBuilder(
|
||
animation: _cloudController,
|
||
builder: (context, child) {
|
||
final dx = sin(_cloudController.value * 2 * pi) * 3;
|
||
final opacity =
|
||
0.65 +
|
||
0.12 * sin(_cloudController.value * 2 * pi);
|
||
return Transform.translate(
|
||
offset: Offset(dx, 0),
|
||
child: Opacity(
|
||
opacity: opacity.clamp(0, 1),
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: Image.asset(
|
||
'assets/clouds.png',
|
||
width: cloudWidth,
|
||
height: 92,
|
||
fit: BoxFit.contain,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
SizedBox(
|
||
width: cloudWidth,
|
||
height: rainContainerHeight,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
CustomPaint(
|
||
size: Size.infinite,
|
||
painter: _BinaryRainPainter(
|
||
_drops,
|
||
cols,
|
||
cs.primary,
|
||
glow: _labelGlow,
|
||
),
|
||
),
|
||
Positioned(
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: AnimatedSwitcher(
|
||
duration: const Duration(milliseconds: 300),
|
||
transitionBuilder: (child, animation) =>
|
||
FadeTransition(
|
||
opacity: animation,
|
||
child: child,
|
||
),
|
||
child: Text(
|
||
_statusLabel,
|
||
key: ValueKey(_status),
|
||
style: Theme.of(context).textTheme.titleMedium
|
||
?.copyWith(
|
||
color: cs.onSurface.withAlpha(
|
||
(0.75 * 255).round(),
|
||
),
|
||
shadows: [
|
||
Shadow(
|
||
blurRadius: 10 * _labelGlow,
|
||
color: cs.primary.withAlpha(
|
||
(0.6 * 255 * _labelGlow).round(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Positioned.fill(
|
||
child: CustomPaint(
|
||
painter: _SparkPainter(_sparks, cs.primary),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Drop {
|
||
int col;
|
||
double y;
|
||
double speed;
|
||
_Drop({required this.col, required this.y, required this.speed});
|
||
}
|
||
|
||
class _Spark {
|
||
double x;
|
||
double y;
|
||
double size;
|
||
double alpha;
|
||
int life;
|
||
|
||
_Spark({
|
||
required this.x,
|
||
required this.y,
|
||
required this.size,
|
||
required this.life,
|
||
}) : alpha = 1.0;
|
||
}
|
||
|
||
class _M3DialogFadeThrough extends StatelessWidget {
|
||
const _M3DialogFadeThrough({
|
||
required this.animation,
|
||
required this.secondaryAnimation,
|
||
required this.child,
|
||
});
|
||
|
||
final Animation<double> animation;
|
||
final Animation<double> secondaryAnimation;
|
||
final Widget child;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AnimatedBuilder(
|
||
animation: Listenable.merge([animation, secondaryAnimation]),
|
||
builder: (context, _) {
|
||
final t = animation.value;
|
||
final st = secondaryAnimation.value;
|
||
|
||
// Incoming: fade in from t=0.3..1.0
|
||
final enterT = ((t - 0.3) / 0.7).clamp(0.0, 1.0);
|
||
final enterOpacity = M3Motion.expressiveDecelerate.transform(enterT);
|
||
|
||
// Outgoing: fade out in first 35% of the secondary animation.
|
||
final exitOpacity = (1.0 - (st / 0.35).clamp(0.0, 1.0));
|
||
|
||
final slideY = 0.02 * (1.0 - enterT);
|
||
|
||
return Opacity(
|
||
opacity: exitOpacity,
|
||
child: Transform.translate(
|
||
offset: Offset(0, slideY * 100),
|
||
child: Opacity(opacity: enterOpacity, child: child),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _BinaryRainPainter extends CustomPainter {
|
||
static const double fontSize = 16;
|
||
final List<_Drop> drops;
|
||
final int cols;
|
||
final Color textColor;
|
||
final double glow;
|
||
|
||
_BinaryRainPainter(this.drops, this.cols, this.textColor, {this.glow = 0.0});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final textStyle = TextStyle(
|
||
color: textColor,
|
||
fontSize: fontSize,
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
);
|
||
final cellW = size.width / cols;
|
||
|
||
for (final d in drops) {
|
||
final text = (Random().nextBool() ? '1' : '0');
|
||
final tp = TextPainter(
|
||
text: TextSpan(text: text, style: textStyle),
|
||
textAlign: TextAlign.center,
|
||
textDirection: TextDirection.ltr,
|
||
)..layout();
|
||
final x = d.col * cellW + (cellW - tp.width) / 2;
|
||
final y = d.y;
|
||
tp.paint(canvas, Offset(x, y));
|
||
}
|
||
|
||
// glow overlay (subtle)
|
||
if (glow > 0) {
|
||
final glowPaint = Paint()
|
||
..color = textColor.withValues(alpha: (glow * 0.2).clamp(0.0, 1.0));
|
||
canvas.drawRect(
|
||
Rect.fromLTWH(0, size.height - 24, size.width, 24),
|
||
glowPaint,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _BinaryRainPainter old) => true;
|
||
}
|
||
|
||
class _SparkPainter extends CustomPainter {
|
||
final List<_Spark> sparks;
|
||
final Color color;
|
||
|
||
_SparkPainter(this.sparks, this.color);
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
for (final s in sparks) {
|
||
final paint = Paint()
|
||
..color = color.withValues(alpha: s.alpha.clamp(0.0, 1.0));
|
||
canvas.drawCircle(Offset(s.x, s.y), s.size, paint);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _SparkPainter old) => true;
|
||
}
|