OTA Updates for adnroid app and web apk uploader

This commit is contained in:
2026-03-15 19:24:34 +08:00
parent 9bbaf67fef
commit 6fd3b66251
12 changed files with 1193 additions and 151 deletions
+405 -30
View File
@@ -3,42 +3,243 @@ 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});
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> {
static const int cols = 20;
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(() {
if (_rng.nextDouble() < 0.3 || _drops.isEmpty) {
// 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() * 4 + 2,
speed: _rng.nextDouble() * (maxSpeed - minSpeed) + minSpeed,
),
);
}
_drops.removeWhere((d) => d.y > MediaQuery.of(context).size.height);
// 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;
}
});
}
@@ -46,6 +247,7 @@ class _UpdateCheckingScreenState extends State<UpdateCheckingScreen> {
@override
void dispose() {
_timer?.cancel();
_cloudController.dispose();
super.dispose();
}
@@ -55,29 +257,118 @@ class _UpdateCheckingScreenState extends State<UpdateCheckingScreen> {
return Scaffold(
backgroundColor: cs.surface,
body: SafeArea(
child: Column(
children: [
const SizedBox(height: 40),
Hero(
tag: 'tasq-logo',
child: Image.asset('assets/tasq_ico.png', height: 80, width: 80),
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),
],
),
const SizedBox(height: 24),
Expanded(
child: CustomPaint(
size: Size.infinite,
painter: _BinaryRainPainter(_drops, cols, cs.primary),
),
),
const SizedBox(height: 24),
Text(
'Checking for updates...',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: cs.onSurface.withAlpha((0.75 * 255).round()),
),
),
const SizedBox(height: 40),
],
),
),
),
);
@@ -91,17 +382,72 @@ class _Drop {
_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);
_BinaryRainPainter(this.drops, this.cols, this.textColor, {this.glow = 0.0});
@override
void paint(Canvas canvas, Size size) {
// paint variable not needed when drawing text
final textStyle = TextStyle(
color: textColor,
fontSize: fontSize,
@@ -120,8 +466,37 @@ class _BinaryRainPainter extends CustomPainter {
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;
}