OTA Updates for adnroid app and web apk uploader
This commit is contained in:
@@ -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-3–style 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user