tasq/lib/screens/update_check_screen.dart

503 lines
15 KiB
Dart
Raw 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.

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-3style 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;
}