OTA Updates for adnroid app and web apk uploader

This commit is contained in:
Marc Rejohn Castillano 2026-03-15 19:24:34 +08:00
parent 9bbaf67fef
commit 6fd3b66251
12 changed files with 1193 additions and 151 deletions

BIN
assets/clouds.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -615,46 +615,69 @@ class UpdateCheckWrapper extends StatefulWidget {
class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
bool _done = false;
AppUpdateInfo? _info;
@override
void initState() {
super.initState();
_performCheck();
}
Future<AppUpdateInfo> _checkForUpdates() =>
AppUpdateService.instance.checkForUpdate();
Future<void> _performCheck() async {
try {
_info = await AppUpdateService.instance.checkForUpdate();
} catch (e) {
debugPrint('update check failed: $e');
}
if (mounted) {
setState(() => _done = true);
if (_info?.isUpdateAvailable == true) {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog(
context: globalNavigatorKey.currentContext!,
barrierDismissible: !_info!.isForceUpdate,
builder: (_) => UpdateDialog(info: _info!),
);
});
Future<void> _handleUpdateComplete(AppUpdateInfo? info) async {
if (!mounted) return;
if (info?.isUpdateAvailable == true) {
// Keep the update-check screen visible while the dialog is shown.
// Use a safe context reference; fall back to the wrapper's own context.
final dialogContext = globalNavigatorKey.currentContext ?? context;
try {
await showDialog(
context: dialogContext,
barrierDismissible: !(info?.isForceUpdate ?? false),
builder: (_) => UpdateDialog(info: info!),
);
} catch (_) {
// If the dialog fails (rare), continue to the next screen.
}
}
if (!mounted) return;
setState(() {
_done = true;
});
}
@override
Widget build(BuildContext context) {
if (!_done) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
home: const UpdateCheckingScreen(),
);
}
return const NotificationBridge(child: TasqApp());
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
home: AnimatedSwitcher(
duration: const Duration(milliseconds: 420),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(begin: 0.94, end: 1.0).animate(curved),
child: child,
),
);
},
child: kIsWeb
? const NotificationBridge(child: TasqApp())
: (_done
? const NotificationBridge(child: TasqApp())
: UpdateCheckingScreen(
checkForUpdates: _checkForUpdates,
onCompleted: _handleUpdateComplete,
)),
),
);
}
}

View File

@ -9,6 +9,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import '../../services/ai_service.dart';
import '../../utils/snackbar.dart';
import '../../widgets/gemini_animated_text_field.dart';
/// A simple admin-only web page allowing the upload of a new APK and the
/// associated metadata. After the APK is uploaded to the "apk_updates"
/// bucket the `app_versions` table is updated and any older rows are removed
@ -26,6 +30,11 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
final _minController = TextEditingController();
final _notesController = TextEditingController();
quill.QuillController? _quillController;
final FocusNode _quillFocusNode = FocusNode();
final ScrollController _quillScrollController = ScrollController();
bool _isImprovingNotes = false;
String? _existingVersion;
// We store release notes as plain text for compatibility; existing
// Quill delta JSON in the database will be parsed and displayed by
// the Android update dialog renderer.
@ -44,13 +53,83 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
@override
void initState() {
super.initState();
// Always have a controller so web edits do not reset when the widget rebuilds.
_setQuillController(quill.QuillController.basic());
_loadCurrent();
}
void _setQuillController(quill.QuillController controller) {
_quillController?.removeListener(_updateQuillToolbar);
_quillController = controller;
_quillController?.addListener(_updateQuillToolbar);
}
void _updateQuillToolbar() {
if (mounted) setState(() {});
}
void _toggleAttribute(quill.Attribute attribute) {
final current = _quillController?.getSelectionStyle();
if (current?.attributes.containsKey(attribute.key) == true) {
_quillController?.formatSelection(quill.Attribute.clone(attribute, null));
} else {
_quillController?.formatSelection(attribute);
}
}
int? _currentHeaderLevel() {
final attrs = _quillController?.getSelectionStyle().attributes;
if (attrs == null) return null;
final Object? headerValue = attrs[quill.Attribute.header.key];
if (headerValue is quill.HeaderAttribute) {
return headerValue.value;
}
if (headerValue is int) {
return headerValue;
}
return null;
}
void _setHeader(int? level) {
if (level == null) {
_quillController?.formatSelection(
quill.Attribute.clone(quill.Attribute.header, null),
);
} else {
_quillController?.formatSelection(quill.HeaderAttribute(level: level));
}
}
Widget _buildToolbarIcon({
required IconData icon,
required String tooltip,
required bool isActive,
required VoidCallback onPressed,
}) {
final theme = Theme.of(context);
return IconButton(
tooltip: tooltip,
icon: Icon(icon),
color: isActive
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withAlpha((0.75 * 255).round()),
onPressed: onPressed,
splashRadius: 20,
);
}
Future<void> _loadCurrent() async {
try {
final client = Supabase.instance.client;
final rows = await client.from('app_versions').select().maybeSingle();
// We will update the UI once we have the values so the initial load
// populates the form fields and editor reliably.
String? existingVersion;
String? versionText;
String? minVersionText;
quill.QuillController? quillController;
// when using text versions we can't rely on server-side ordering; instead
// parse locally and choose the greatest semantic version.
if (rows is List) {
@ -75,46 +154,78 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
}
}
if (bestRow != null) {
_versionController.text = bestRow['version_code']?.toString() ?? '';
_minController.text =
bestRow['min_version_required']?.toString() ?? '';
existingVersion = bestRow['version_code']?.toString();
versionText = bestRow['version_code']?.toString() ?? '';
minVersionText = bestRow['min_version_required']?.toString() ?? '';
final rn = bestRow['release_notes'] ?? '';
if (rn is String && rn.trim().isNotEmpty) {
try {
final parsed = jsonDecode(rn);
if (parsed is List) {
final doc = quill.Document.fromJson(parsed);
_quillController = quill.QuillController(
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
} else {
_notesController.text = rn.toString();
final doc = quill.Document()..insert(0, rn.toString());
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
}
} catch (_) {
_notesController.text = rn.toString();
final doc = quill.Document()..insert(0, rn.toString());
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
}
}
}
} else if (rows is Map<String, dynamic>) {
_versionController.text = rows['version_code']?.toString() ?? '';
_minController.text = rows['min_version_required']?.toString() ?? '';
existingVersion = rows['version_code']?.toString();
versionText = rows['version_code']?.toString() ?? '';
minVersionText = rows['min_version_required']?.toString() ?? '';
final rn = rows['release_notes'] ?? '';
try {
final parsed = jsonDecode(rn);
if (parsed is List) {
final doc = quill.Document.fromJson(parsed);
_quillController = quill.QuillController(
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
} else {
_notesController.text = rn as String;
final doc = quill.Document()..insert(0, rn.toString());
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
}
} catch (_) {
_notesController.text = rn as String;
final doc = quill.Document()..insert(0, rn.toString());
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
}
}
if (mounted) {
setState(() {
_existingVersion = existingVersion;
if (versionText != null) {
_versionController.text = versionText;
}
if (minVersionText != null) {
_minController.text = minVersionText;
}
if (quillController != null) {
_setQuillController(quillController);
}
});
}
} catch (_) {}
}
@ -123,6 +234,8 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
_versionController.dispose();
_minController.dispose();
_notesController.dispose();
_quillFocusNode.dispose();
_quillScrollController.dispose();
super.dispose();
}
@ -139,6 +252,69 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
}
}
Future<void> _improveReleaseNotesWithGemini(BuildContext context) async {
final controller = _quillController;
if (controller == null) return;
final plain = controller.document.toPlainText().trim();
if (plain.isEmpty) {
if (!context.mounted) return;
showWarningSnackBar(context, 'Please enter some release notes first');
return;
}
setState(() {
_isImprovingNotes = true;
});
try {
final aiService = AiService();
final from = _existingVersion?.trim();
final to = _versionController.text.trim();
final prompt = StringBuffer(
'Improve these release notes for a mobile app update. '
'Keep the message concise, clear, and professional. '
'The notes may include formatting (bold, italic, underline, headings, lists, code blocks, links). '
'Do NOT change or remove existing markdown-style formatting markers. '
'Return ONLY the improved release notes in plain text using markdown-style markers (e.g. **bold**, *italic*, `code`, # Heading, - list, [link](url)). '
'Do not output HTML, JSON, or any extra commentary. '
'If the input already contains markdown markers, keep them and do not rewrite them into a different format.',
);
if (from != null && from.isNotEmpty && to.isNotEmpty) {
prompt.write(' Update is from $from$to.');
} else if (to.isNotEmpty) {
prompt.write(' Update version: $to.');
}
final improved = await aiService.enhanceText(
plain,
promptInstruction: prompt.toString(),
);
final trimmed = improved.trim();
final docLen = controller.document.length;
controller.replaceText(
0,
docLen - 1,
trimmed,
TextSelection.collapsed(offset: trimmed.length),
);
if (context.mounted) {
showSuccessSnackBar(context, 'Release notes improved with Gemini');
}
} catch (e) {
if (context.mounted) {
showErrorSnackBar(context, 'Gemini failed: $e');
}
} finally {
if (mounted) {
setState(() {
_isImprovingNotes = false;
});
}
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_apkBytes == null || _apkName == null) {
@ -202,6 +378,8 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
});
});
// Upload can occasionally stall on web; enforce a timeout so the UI
// shows an error instead of hanging at 95%.
final uploadRes = await client.storage
.from('apk_updates')
.uploadBinary(
@ -211,6 +389,10 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
upsert: true,
contentType: 'application/vnd.android.package-archive',
),
)
.timeout(
const Duration(minutes: 3),
onTimeout: () => throw Exception('Upload timed out. Please retry.'),
);
stopwatch.stop();
_logs.add('Upload finished (took ${stopwatch.elapsed.inSeconds}s)');
@ -273,7 +455,11 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
}
} catch (e) {
_logs.add('Error during upload: $e');
setState(() => _error = e.toString());
setState(() {
_error = e.toString();
_progress = null;
_realProgress = 0;
});
} finally {
_startDelayTimer?.cancel();
_startDelayTimer = null;
@ -328,16 +514,252 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
validator: (v) =>
(v == null || v.isEmpty) ? 'Required' : null,
),
// Release notes: use Quill rich editor when available (web)
// Release notes: use Quill rich editor when available (web).
if (_quillController != null || kIsWeb) ...[
// toolbar omitted (package version may not export it)
const SizedBox(height: 8),
SizedBox(
height: 200,
child: quill.QuillEditor.basic(
controller:
_quillController ??
quill.QuillController.basic(),
GeminiAnimatedBorder(
isProcessing: _isImprovingNotes,
borderRadius: 12,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outlineVariant,
),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quill toolbar (basic formatting + headings/lists/links/code)
const SizedBox(height: 8),
Row(
children: [
_buildToolbarIcon(
icon: Icons.format_bold,
tooltip: 'Bold',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.bold.key,
) ==
true,
onPressed: () {
_toggleAttribute(quill.Attribute.bold);
},
),
_buildToolbarIcon(
icon: Icons.format_italic,
tooltip: 'Italic',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.italic.key,
) ==
true,
onPressed: () {
_toggleAttribute(
quill.Attribute.italic,
);
},
),
_buildToolbarIcon(
icon: Icons.format_underline,
tooltip: 'Underline',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.underline.key,
) ==
true,
onPressed: () {
_toggleAttribute(
quill.Attribute.underline,
);
},
),
_buildToolbarIcon(
icon: Icons.format_list_bulleted,
tooltip: 'Bullet list',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.ul.key,
) ==
true,
onPressed: () {
_toggleAttribute(quill.Attribute.ul);
},
),
_buildToolbarIcon(
icon: Icons.code,
tooltip: 'Code block',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.codeBlock.key,
) ==
true,
onPressed: () {
_toggleAttribute(
quill.Attribute.codeBlock,
);
},
),
_buildToolbarIcon(
icon: Icons.format_size,
tooltip: 'Heading 1',
isActive: _currentHeaderLevel() == 1,
onPressed: () {
_setHeader(1);
},
),
_buildToolbarIcon(
icon: Icons.format_size,
tooltip: 'Heading 2',
isActive: _currentHeaderLevel() == 2,
onPressed: () {
_setHeader(2);
},
),
_buildToolbarIcon(
icon: Icons.format_size,
tooltip: 'Heading 3',
isActive: _currentHeaderLevel() == 3,
onPressed: () {
_setHeader(3);
},
),
_buildToolbarIcon(
icon: Icons.link,
tooltip: 'Link',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.link.key,
) ==
true,
onPressed: () async {
final selection =
_quillController!.selection;
if (selection.isCollapsed) return;
final current = _quillController!
.getSelectionStyle();
final existing =
current.attributes[quill
.Attribute
.link
.key];
final url = await showDialog<String>(
context: context,
builder: (context) {
final ctrl = TextEditingController(
text: existing?.value as String?,
);
return AlertDialog(
title: const Text('Insert link'),
content: TextField(
controller: ctrl,
decoration:
const InputDecoration(
labelText: 'URL',
),
keyboardType: TextInputType.url,
),
actions: [
TextButton(
onPressed: () => Navigator.of(
context,
).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(
context,
).pop(ctrl.text.trim());
},
child: const Text('OK'),
),
],
);
},
);
if (url == null) return;
if (url.isEmpty) {
_toggleAttribute(
quill.Attribute.link,
);
} else {
_quillController!.formatSelection(
quill.LinkAttribute(url),
);
}
},
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 200,
child: quill.QuillEditor.basic(
controller: _quillController!,
focusNode: _quillFocusNode,
scrollController: _quillScrollController,
config: quill.QuillEditorConfig(
scrollable: true,
padding: EdgeInsets.zero,
),
),
),
const SizedBox(height: 8),
Row(
children: [
FilledButton.icon(
onPressed: _isImprovingNotes
? null
: () =>
_improveReleaseNotesWithGemini(
context,
),
icon: _isImprovingNotes
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.auto_awesome),
label: const Text('Improve'),
),
if (_isImprovingNotes) ...[
const SizedBox(width: 12),
Text(
'Improving...',
style: Theme.of(
context,
).textTheme.bodySmall,
),
],
],
),
],
),
),
),
] else ...[

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
/// A simple, stylized cloud used in the update check splash screen.
///
/// The cloud is drawn using a few overlapping circles (ovals) and a
/// horizontal base, giving a soft, layered cloud look.
class CloudOverlay extends StatelessWidget {
final Color color;
final double width;
final double height;
const CloudOverlay({
super.key,
required this.color,
required this.width,
required this.height,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(width, height),
painter: _CloudPainter(color),
);
}
}
class _CloudPainter extends CustomPainter {
final Color color;
_CloudPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
final w = size.width;
final h = size.height;
// Base ellipses
canvas.drawOval(
Rect.fromCenter(
center: Offset(w * 0.25, h * 0.55),
width: w * 0.55,
height: h * 0.6,
),
paint,
);
canvas.drawOval(
Rect.fromCenter(
center: Offset(w * 0.5, h * 0.45),
width: w * 0.6,
height: h * 0.7,
),
paint,
);
canvas.drawOval(
Rect.fromCenter(
center: Offset(w * 0.75, h * 0.55),
width: w * 0.55,
height: h * 0.6,
),
paint,
);
// base rectangle for cloud bottom
final bottomRect = Rect.fromLTWH(0, h * 0.55, w, h * 0.35);
canvas.drawRRect(
RRect.fromRectAndRadius(bottomRect, Radius.circular(h * 0.18)),
paint,
);
}
@override
bool shouldRepaint(covariant _CloudPainter old) => old.color != color;
}

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

View File

@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
// We render Quill deltas here without depending on the flutter_quill editor
// API to avoid analyzer/API mismatches; we support common inline styles.
import 'package:flutter_quill/flutter_quill.dart' as quill;
import '../models/app_version.dart';
import '../services/app_update_service.dart';
@ -25,6 +24,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
bool _failed = false;
List<dynamic>? _notesDelta;
String? _notesPlain;
quill.QuillController? _notesController;
final FocusNode _notesFocusNode = FocusNode();
final ScrollController _notesScrollController = ScrollController();
Future<void> _startDownload() async {
setState(() {
@ -56,11 +58,19 @@ class _UpdateDialogState extends State<UpdateDialog> {
final notes = widget.info.latestVersion?.releaseNotes ?? '';
// parse release notes into a Quill delta list if possible
if (_notesDelta == null && _notesPlain == null && notes.isNotEmpty) {
if ((_notesDelta == null && _notesPlain == null) && notes.isNotEmpty) {
try {
final parsed = jsonDecode(notes);
if (parsed is List) {
_notesDelta = parsed;
_notesController = quill.QuillController(
document: quill.Document.fromJson(parsed),
selection: const TextSelection.collapsed(offset: 0),
readOnly: true,
);
// Prevent keyboard focus while still allowing text selection + copy.
_notesFocusNode.canRequestFocus = false;
} else {
_notesPlain = notes;
}
@ -85,20 +95,36 @@ class _UpdateDialogState extends State<UpdateDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Show current + new versions
if (widget.info.latestVersion != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Version: ${widget.info.currentBuildNumber}${widget.info.latestVersion!.versionCode}',
style: Theme.of(context).textTheme.bodyMedium,
),
),
// Render release notes: prefer Quill delta if available
if (_notesDelta != null)
if (_notesController != null)
SizedBox(
height: 250,
child: SingleChildScrollView(
child: RichText(
text: _deltaToTextSpan(
_notesDelta!,
Theme.of(context).textTheme.bodyMedium,
),
child: quill.QuillEditor.basic(
controller: _notesController!,
focusNode: _notesFocusNode,
scrollController: _notesScrollController,
config: const quill.QuillEditorConfig(
// Keep the editor readable/copyable but avoid showing the
// selection toolbar or receiving keyboard focus.
autoFocus: false,
showCursor: false,
enableInteractiveSelection: true,
enableSelectionToolbar: false,
// Prevent the context menu / selection toolbar from showing.
contextMenuBuilder: _noContextMenu,
),
),
),
if (_notesDelta == null &&
if (_notesController == null &&
_notesPlain != null &&
_notesPlain!.isNotEmpty)
SizedBox(
@ -163,64 +189,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
return actions;
}
TextSpan _deltaToTextSpan(List<dynamic> delta, TextStyle? baseStyle) {
final children = <TextSpan>[];
TextStyle styleFromAttributes(Map? attrs) {
var s = baseStyle ?? const TextStyle();
if (attrs == null) return s;
if (attrs['header'] != null) {
final level = attrs['header'] is int ? attrs['header'] as int : 1;
s = s.copyWith(
fontSize: 18.0 - (level - 1) * 2,
fontWeight: FontWeight.bold,
);
}
if (attrs['bold'] == true) s = s.copyWith(fontWeight: FontWeight.bold);
if (attrs['italic'] == true) s = s.copyWith(fontStyle: FontStyle.italic);
if (attrs['underline'] == true) {
s = s.copyWith(decoration: TextDecoration.underline);
}
if (attrs['strike'] == true) {
s = s.copyWith(decoration: TextDecoration.lineThrough);
}
if (attrs['color'] is String) {
try {
final col = attrs['color'] as String;
// simple support for hex colors like #rrggbb
if (col.startsWith('#') && (col.length == 7 || col.length == 9)) {
final hex = col.replaceFirst('#', '');
final value = int.parse(hex, radix: 16);
s = s.copyWith(
color: Color((hex.length == 6 ? 0xFF000000 : 0) | value),
);
}
} catch (_) {}
}
return s;
}
for (final op in delta) {
if (op is Map && op.containsKey('insert')) {
final insert = op['insert'];
final attrs = op['attributes'] as Map?;
String text;
if (insert is String) {
text = insert;
} else if (insert is Map && insert.containsKey('image')) {
// render image as alt text placeholder
text = '[image]';
} else {
text = insert.toString();
}
children.add(TextSpan(text: text, style: styleFromAttributes(attrs)));
}
}
return TextSpan(children: children, style: baseStyle);
}
@override
void initState() {
super.initState();
@ -231,6 +199,14 @@ class _UpdateDialogState extends State<UpdateDialog> {
final parsed = jsonDecode(notes);
if (parsed is List) {
_notesDelta = parsed;
_notesController = quill.QuillController(
document: quill.Document.fromJson(parsed),
selection: const TextSelection.collapsed(offset: 0),
readOnly: true,
);
// Prevent keyboard focus while still allowing text selection + copy.
_notesFocusNode.canRequestFocus = false;
} else {
_notesPlain = notes;
}
@ -242,6 +218,11 @@ class _UpdateDialogState extends State<UpdateDialog> {
@override
void dispose() {
_notesFocusNode.dispose();
_notesScrollController.dispose();
super.dispose();
}
static Widget _noContextMenu(BuildContext context, Object state) =>
const SizedBox.shrink();
}

View File

@ -0,0 +1,32 @@
-- Create table that holds the latest version information for the
-- selfhosted Android update mechanism. Mobile clients query this table
-- anonymously and compare the returned `version_code`/`min_version_required`
-- against their own build number.
CREATE TABLE IF NOT EXISTS public.app_versions (
version_code text NOT NULL,
min_version_required text NOT NULL,
download_url text NOT NULL,
release_notes text DEFAULT ''
);
-- index for quick ordering by version_code
CREATE UNIQUE INDEX IF NOT EXISTS app_versions_version_code_idx
ON public.app_versions (version_code);
-- Enable realtime for clients if desired
ALTER PUBLICATION supabase_realtime ADD TABLE public.app_versions;
-- Row level security and simple policies
ALTER TABLE public.app_versions ENABLE ROW LEVEL SECURITY;
-- allow everyone (including anon) to read the table
DROP POLICY IF EXISTS "app_versions_public_read" ON public.app_versions;
CREATE POLICY "app_versions_public_read" ON public.app_versions
FOR SELECT USING (true);
-- only the service role (e.g. migrations or trusted server) may write
DROP POLICY IF EXISTS "app_versions_service_write" ON public.app_versions;
CREATE POLICY "app_versions_service_write" ON public.app_versions
FOR ALL USING (auth.role() = 'service_role')
WITH CHECK (auth.role() = 'service_role');

View File

@ -0,0 +1,48 @@
-- Ensure the buckets exist by inserting directly into the system table.
-- This matches the pattern used by older migrations and avoids relying on
-- optional SQL wrapper functions that may not be installed in all projects.
INSERT INTO storage.buckets (id, name, public)
VALUES ('apk_updates', 'apk_updates', true)
ON CONFLICT (id) DO NOTHING;
-- Ensure a `cors` column exists on storage.buckets for self-hosted installs
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'storage' AND table_name = 'buckets' AND column_name = 'cors'
) THEN
ALTER TABLE storage.buckets ADD COLUMN cors jsonb;
END IF;
END
$$;
-- Set bucket to public (idempotent) and populate a basic CORS rule for local development.
-- Adjust or remove origins as appropriate for production.
-- NOTE: bucket public/CORS is intentionally left to dashboard or manual configuration
-- to ensure compatibility with self-hosted Supabase instances. If you need to
-- programmatically set `public` or `cors` on your environment, run an update
-- in the SQL editor with values appropriate for your deployment.
-- storage object policies for the apk_updates bucket
-- Create policies in the same style as other buckets (e.g. it_service_attachments)
DROP POLICY IF EXISTS "Authenticated users can upload apk_updates" ON storage.objects;
CREATE POLICY "Authenticated users can upload apk_updates"
ON storage.objects FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'apk_updates');
DROP POLICY IF EXISTS "Authenticated users can read apk_updates" ON storage.objects;
CREATE POLICY "Authenticated users can read apk_updates"
ON storage.objects FOR SELECT TO authenticated
USING (bucket_id = 'apk_updates');
DROP POLICY IF EXISTS "Authenticated users can delete apk_updates" ON storage.objects;
CREATE POLICY "Authenticated users can delete apk_updates"
ON storage.objects FOR DELETE TO authenticated
USING (bucket_id = 'apk_updates');
-- Note: some self-hosted Supabase installations may require manual CORS configuration
-- via the dashboard. This migration sets a CORS value in the buckets table when
-- possible and marks the bucket public; if uploads still fail with CORS errors,
-- update the bucket settings in the Supabase UI to add the correct origin(s).

View File

@ -0,0 +1,31 @@
-- Allow authenticated admin/dispatcher/it_staff profiles to write app_versions
-- while preserving service_role write access. This makes the web admin
-- uploader work for privileged users without exposing writes to all auth users.
-- Drop the restrictive service-only policy (if present)
DROP POLICY IF EXISTS "app_versions_service_write" ON public.app_versions;
-- Create a combined policy allowing either the service_role or a profile with
-- an elevated role to perform inserts/updates/deletes.
CREATE POLICY "app_versions_service_or_admin_write" ON public.app_versions
FOR ALL
USING (
auth.role() = 'service_role'
OR EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher', 'it_staff')
)
)
WITH CHECK (
auth.role() = 'service_role'
OR EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher', 'it_staff')
)
);
-- Notes:
-- - Run this migration using the service_role key (or apply via the Supabase
-- SQL editor) so the new policy is created successfully.
-- - If your project stores roles in a different table or column, adjust the
-- `SELECT` accordingly.

View File

@ -0,0 +1,12 @@
-- Grant appropriate privileges on app_versions so authenticated users
-- can perform writes permitted by RLS policies and anonymous users can read.
-- Grant read access to anonymous (public) clients
GRANT SELECT ON public.app_versions TO anon;
-- Grant write privileges to authenticated role (RLS still applies)
GRANT SELECT, INSERT, UPDATE, DELETE ON public.app_versions TO authenticated;
-- Notes:
-- - Run this with a service_role or a superuser in the SQL editor.
-- - RLS policies still control which authenticated users may actually write.

View File

@ -0,0 +1,11 @@
-- Ensure logical replication can publish deletes/updates for app_versions
-- by setting a replica identity. This is idempotent and safe to run
-- using the service_role key or from the Supabase SQL editor.
-- Set replica identity to FULL so deletes/updates include whole row
-- when no primary key is present.
ALTER TABLE public.app_versions REPLICA IDENTITY FULL;
-- Optional alternative: if you prefer a primary key instead of FULL,
-- run the following (only if version_code is guaranteed unique):
-- ALTER TABLE public.app_versions ADD CONSTRAINT app_versions_pkey PRIMARY KEY (version_code);

View File

@ -0,0 +1,32 @@
-- Migration: convert numeric version columns to text to support semantic versions
-- This is idempotent: it only alters columns if they are not already text.
DO $$
BEGIN
-- Alter version_code to text if needed
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'app_versions' AND column_name = 'version_code' AND data_type <> 'text'
) THEN
ALTER TABLE public.app_versions
ALTER COLUMN version_code TYPE text USING version_code::text;
END IF;
-- Alter min_version_required to text if needed
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'app_versions' AND column_name = 'min_version_required' AND data_type <> 'text'
) THEN
ALTER TABLE public.app_versions
ALTER COLUMN min_version_required TYPE text USING min_version_required::text;
END IF;
-- Recreate unique index on version_code to ensure correct index type
DROP INDEX IF EXISTS app_versions_version_code_idx;
CREATE UNIQUE INDEX IF NOT EXISTS app_versions_version_code_idx ON public.app_versions (version_code);
END
$$;
-- Notes:
-- - Run this migration using the service_role key or from the Supabase SQL editor.
-- - After running, clients using string semantic versions (e.g., '0.1.1') will work.