From 6fd3b662517a982e91ce7aa9eeaa17760356bff0 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 15 Mar 2026 19:24:34 +0800 Subject: [PATCH] OTA Updates for adnroid app and web apk uploader --- assets/clouds.png | Bin 0 -> 2370 bytes lib/main.dart | 87 ++-- lib/screens/admin/app_update_screen.dart | 462 +++++++++++++++++- lib/screens/cloud_overlay.dart | 75 +++ lib/screens/update_check_screen.dart | 435 +++++++++++++++-- lib/widgets/update_dialog.dart | 119 ++--- .../20260311120000_add_app_versions_table.sql | 32 ++ .../20260311121000_create_storage_buckets.sql | 48 ++ ...1123000_allow_admin_app_versions_write.sql | 31 ++ ...0260311124000_grant_app_versions_privs.sql | 12 + ...5000_set_replica_identity_app_versions.sql | 11 + ...60311130000_alter_app_versions_to_text.sql | 32 ++ 12 files changed, 1193 insertions(+), 151 deletions(-) create mode 100644 assets/clouds.png create mode 100644 lib/screens/cloud_overlay.dart create mode 100644 supabase/migrations/20260311120000_add_app_versions_table.sql create mode 100644 supabase/migrations/20260311121000_create_storage_buckets.sql create mode 100644 supabase/migrations/20260311123000_allow_admin_app_versions_write.sql create mode 100644 supabase/migrations/20260311124000_grant_app_versions_privs.sql create mode 100644 supabase/migrations/20260311125000_set_replica_identity_app_versions.sql create mode 100644 supabase/migrations/20260311130000_alter_app_versions_to_text.sql diff --git a/assets/clouds.png b/assets/clouds.png new file mode 100644 index 0000000000000000000000000000000000000000..768d53c98771a01d45b53acdbda94db1317b295d GIT binary patch literal 2370 zcmaJ@=QkS)98J)Q5VMh3QS=p6v&O4gX%0sB2h*46dwYrQbHEP8kZ5~h6 zidQN%LR3ql!z|%NnovCNA9(ki`@0|Rx##>o-E;3d*jYfp(qI4p0I{+}T|H8t{|GvM zbQcR^em@d{2ursp06n=vjA!xp45tkS3S~@ONT(Q&ck7 zM^BNNhx7p&h#C}(`^PR{0C5}BYtD(`DNy1;4E$~4RfStmQhf`Lk@w#%W73V!N^*cO zLEnx*-ST)wVtOwk^u({}Wei?6GBUE~zE-88&@1u<&I?2e1JT#~KIm>K++-sckePjW zzR$s!5V87L>TEpM%=Lg>vOY=4Zi!kNadnIP%_xeksAlXsid~;4>xa*dJq=jW37$A> zhLq4%8wfG54M6!-Us9+WDYnE2OwQt8y=rc?enM>8)Tn6p9&QYKw<;6XCS$NB&-|4h z*~$3PfWEaYTk0#|O9KHG^lf!d3blzkZf2%oPLBn2u}`$J*@&j{EZX$+VA!;-u=c=? zOf-?+>#Oh2PVko=r1o5Y=`qen`86yMnv-^2!T2iI=d&o zKGA&z4caN77I6}tUSDjvm}!}SIHU2{OV~9whCPm({u3FVo zcWYEVU(g6i(Daga)-zWpCu@g+>aed0#FsDM_Jgib_0WT%;dkZ?(E900oYtHsV-~Mh zZp&lR$F92?rir;_d#<)WK6Gmh&&+%icqnS+ldbxx%tz&e2#d=tRI+Q=1z=+qd^@6T z5y$BQyVNIZC@Zd=>koohO6{)s=~Gp$_@VFGuu|dX>X|OF?{7G*5rF|<{(|>^=cw|9 z31cu_k4j9g8-^`98Q6=aS7Zf3&+41HiDnbD#l*yNm~S$AWxy_G=6sn@uL?zn8k%gJ zq|B+p_kY_LAfcf8mEDvEk2fKo2CFYdrUv`XlY&cC6c(=Wsp(_xUyN48lF2Tizcg)M z@U+eTQr}QGV9#Ey++4SwI)=cQS zrr(GQG3b^b*q&zk0PvIeyNld)Uf1Tymxs9$%GN@#65-~GihEU~RxmK$zq<8W{Y=~L z@hu$^sp_**QbHV8+M}SbqJi0+R3EU2BwyYr%pv2Bj z3~lL#21ZenyC`mLSGe|Bg4mJ-hw-YQOx6#!Suk&X&)cg*YX++wt84V1TZMZYog#^B zITku}?Synn)QVa?yfq7%-{$Wrey%U#^ZsCX7!Cb;*_oUKJrSaf`#7r0=5C!cce5o$ zeKDHrPE%5mb=o=^hq{z&A!)oYWWyL4vkExya4tpJ|3%cDA!)2wm zvuw;&B75X>Io&aJ-rxAHc-W+3i32x*A)=8fHNytOlfjyIh?yt4K8h!ErY)dC)qe>X znnXXB^b(dm6QpckI^zQCGCSY<^jo8^_E< zD)OEz|DJ{V`ecBC{j+;-FbgwI56SrT!$=60o1LG4VT=H>yzUJ8e^NT*i25iuuDEr* zX~e&zBoUW%^ICMnchwCL(${xc+Cr&Yqty?qMZ~jdE5K`&SM{rh9hmlG~~t zHBjQ81qLp)_79RFu$<#5H*O@FLL$B{t5QVKcW>aNdj0OH?%rEcchxCxh=1Kyy<~P| zx3F4!dCP`1k6zrEQ676&PsNKAnWB!XK;ai!&YK}z5-!ED<98=`7O2uDKGJ^ctK~;2 z+4NgMoTVKWGI(DqTn_*1yZOev$MOpBKsL_;RnnN`fG@>~bb_Sy zHbxVFK+J`iUjXpF+}b3q$3~Fe98`^zhVaD4Co0ny zE0j(8Meqy><_x0j^s0PgUE{fg54vfz)e55_l1~erql9;TBUDE7%|Bq=Ek$S_nU!G7 zq6Ce=FCr)AjHG(tydUaVSeU&CZ)FNbT^1qWL5VwfwKIqB^Mfh{g5DG{o^eFF0T;(} z6|XuQcd@CEetvt%ODz3*gg^k(eX8#P`t5|oDu+~?CzV{&wsUwBGp(b}g{$-EX|blL z3*woSaK>40(vY?Mz>3zfDgF;~?cYY{9-Cpg907L`j4@XMiId3k{zlM>67{|>M?y< literal 0 HcmV?d00001 diff --git a/lib/main.dart b/lib/main.dart index 6bee3a02..790ffc40 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -615,46 +615,69 @@ class UpdateCheckWrapper extends StatefulWidget { class _UpdateCheckWrapperState extends State { bool _done = false; - AppUpdateInfo? _info; - @override - void initState() { - super.initState(); - _performCheck(); - } + Future _checkForUpdates() => + AppUpdateService.instance.checkForUpdate(); - Future _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 _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(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, + )), + ), + ); } } diff --git a/lib/screens/admin/app_update_screen.dart b/lib/screens/admin/app_update_screen.dart index 28cab8ae..7bf7ad89 100644 --- a/lib/screens/admin/app_update_screen.dart +++ b/lib/screens/admin/app_update_screen.dart @@ -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 { 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 { @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 _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 { } } 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) { - _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 { _versionController.dispose(); _minController.dispose(); _notesController.dispose(); + _quillFocusNode.dispose(); + _quillScrollController.dispose(); super.dispose(); } @@ -139,6 +252,69 @@ class _AppUpdateScreenState extends ConsumerState { } } + Future _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 _submit() async { if (!_formKey.currentState!.validate()) return; if (_apkBytes == null || _apkName == null) { @@ -202,6 +378,8 @@ class _AppUpdateScreenState extends ConsumerState { }); }); + // 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 { 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 { } } 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 { 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( + 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 ...[ diff --git a/lib/screens/cloud_overlay.dart b/lib/screens/cloud_overlay.dart new file mode 100644 index 00000000..5ebb4c3c --- /dev/null +++ b/lib/screens/cloud_overlay.dart @@ -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; +} diff --git a/lib/screens/update_check_screen.dart b/lib/screens/update_check_screen.dart index e086acb8..e5e962e5 100644 --- a/lib/screens/update_check_screen.dart +++ b/lib/screens/update_check_screen.dart @@ -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 Function()? checkForUpdates; @override State createState() => _UpdateCheckingScreenState(); } -class _UpdateCheckingScreenState extends State { - static const int cols = 20; +class _UpdateCheckingScreenState extends State + 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 _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 _showM3Dialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + }) { + return showGeneralDialog( + 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 { @override void dispose() { _timer?.cancel(); + _cloudController.dispose(); super.dispose(); } @@ -55,29 +257,118 @@ class _UpdateCheckingScreenState extends State { 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 animation; + final Animation 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; +} diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 43f9f431..0778f8b9 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -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 { bool _failed = false; List? _notesDelta; String? _notesPlain; + quill.QuillController? _notesController; + final FocusNode _notesFocusNode = FocusNode(); + final ScrollController _notesScrollController = ScrollController(); Future _startDownload() async { setState(() { @@ -56,11 +58,19 @@ class _UpdateDialogState extends State { 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 { 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 { return actions; } - TextSpan _deltaToTextSpan(List delta, TextStyle? baseStyle) { - final children = []; - - 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 { 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 { @override void dispose() { + _notesFocusNode.dispose(); + _notesScrollController.dispose(); super.dispose(); } + + static Widget _noContextMenu(BuildContext context, Object state) => + const SizedBox.shrink(); } diff --git a/supabase/migrations/20260311120000_add_app_versions_table.sql b/supabase/migrations/20260311120000_add_app_versions_table.sql new file mode 100644 index 00000000..931a18ed --- /dev/null +++ b/supabase/migrations/20260311120000_add_app_versions_table.sql @@ -0,0 +1,32 @@ +-- Create table that holds the latest version information for the +-- self‑hosted 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'); diff --git a/supabase/migrations/20260311121000_create_storage_buckets.sql b/supabase/migrations/20260311121000_create_storage_buckets.sql new file mode 100644 index 00000000..120fdfaa --- /dev/null +++ b/supabase/migrations/20260311121000_create_storage_buckets.sql @@ -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). \ No newline at end of file diff --git a/supabase/migrations/20260311123000_allow_admin_app_versions_write.sql b/supabase/migrations/20260311123000_allow_admin_app_versions_write.sql new file mode 100644 index 00000000..178e1ccf --- /dev/null +++ b/supabase/migrations/20260311123000_allow_admin_app_versions_write.sql @@ -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. diff --git a/supabase/migrations/20260311124000_grant_app_versions_privs.sql b/supabase/migrations/20260311124000_grant_app_versions_privs.sql new file mode 100644 index 00000000..63a85e8f --- /dev/null +++ b/supabase/migrations/20260311124000_grant_app_versions_privs.sql @@ -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. diff --git a/supabase/migrations/20260311125000_set_replica_identity_app_versions.sql b/supabase/migrations/20260311125000_set_replica_identity_app_versions.sql new file mode 100644 index 00000000..e2881bf6 --- /dev/null +++ b/supabase/migrations/20260311125000_set_replica_identity_app_versions.sql @@ -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); diff --git a/supabase/migrations/20260311130000_alter_app_versions_to_text.sql b/supabase/migrations/20260311130000_alter_app_versions_to_text.sql new file mode 100644 index 00000000..2af31c5e --- /dev/null +++ b/supabase/migrations/20260311130000_alter_app_versions_to_text.sql @@ -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.