OTA Updates for adnroid app and web apk uploader
This commit is contained in:
parent
9bbaf67fef
commit
6fd3b66251
BIN
assets/clouds.png
Normal file
BIN
assets/clouds.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -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 {
|
||||
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 {
|
||||
_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!),
|
||||
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(),
|
||||
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,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const NotificationBridge(child: TasqApp());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
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 ??
|
||||
quill.QuillController.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 ...[
|
||||
|
|
|
|||
75
lib/screens/cloud_overlay.dart
Normal file
75
lib/screens/cloud_overlay.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -3,42 +3,243 @@ import 'dart:math';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/app_version.dart';
|
||||
import '../widgets/update_dialog.dart';
|
||||
import '../theme/m3_motion.dart';
|
||||
|
||||
/// Simple binary rain animation with a label for the update check splash.
|
||||
enum UpdateCheckStatus { checking, noInternet, upToDate, updateFound, error }
|
||||
|
||||
/// Simple binary rain animation with a label for the update check splash.
|
||||
class UpdateCheckingScreen extends StatefulWidget {
|
||||
const UpdateCheckingScreen({super.key});
|
||||
const UpdateCheckingScreen({
|
||||
super.key,
|
||||
this.onCompleted,
|
||||
this.checkForUpdates,
|
||||
});
|
||||
|
||||
/// Called once the check completes (success, no internet, etc.).
|
||||
final void Function(AppUpdateInfo? info)? onCompleted;
|
||||
|
||||
/// Optional update check implementation.
|
||||
///
|
||||
/// If null, the screen will simulate a check and mark it as up-to-date.
|
||||
final Future<AppUpdateInfo> Function()? checkForUpdates;
|
||||
|
||||
@override
|
||||
State<UpdateCheckingScreen> createState() => _UpdateCheckingScreenState();
|
||||
}
|
||||
|
||||
class _UpdateCheckingScreenState extends State<UpdateCheckingScreen> {
|
||||
static const int cols = 20;
|
||||
class _UpdateCheckingScreenState extends State<UpdateCheckingScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const int cols = 14;
|
||||
static const int minDrops = 8;
|
||||
static const double minSpeed = 2.0;
|
||||
static const double maxSpeed = 4.5;
|
||||
static const double rainHeight = 150.0;
|
||||
static const double labelZoneHeight = 32.0;
|
||||
static const double rainContainerHeight = rainHeight + labelZoneHeight;
|
||||
static const double cloudWidth = 220.0;
|
||||
|
||||
final List<_Drop> _drops = [];
|
||||
final List<_Spark> _sparks = [];
|
||||
Timer? _timer;
|
||||
final Random _rng = Random();
|
||||
|
||||
late final AnimationController _cloudController;
|
||||
double _labelGlow = 0.0; // 0..1
|
||||
UpdateCheckStatus _status = UpdateCheckStatus.checking;
|
||||
bool _hasShownUpdateDialog = false;
|
||||
AppUpdateInfo? _info;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cloudController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 3),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
||||
_tick();
|
||||
});
|
||||
|
||||
_runUpdateCheck();
|
||||
}
|
||||
|
||||
String get _statusLabel {
|
||||
switch (_status) {
|
||||
case UpdateCheckStatus.checking:
|
||||
return 'Checking for updates...';
|
||||
case UpdateCheckStatus.noInternet:
|
||||
return 'No internet connection';
|
||||
case UpdateCheckStatus.upToDate:
|
||||
if ((_info?.currentBuildNumber.isNotEmpty) == true) {
|
||||
return 'Up to date (${_info!.currentBuildNumber})';
|
||||
}
|
||||
return 'App is already up to date';
|
||||
case UpdateCheckStatus.updateFound:
|
||||
final current = _info?.currentBuildNumber ?? '';
|
||||
final latest = _info?.latestVersion?.versionCode ?? '';
|
||||
if (current.isNotEmpty && latest.isNotEmpty) {
|
||||
return 'Update found ($current → $latest)';
|
||||
}
|
||||
return 'Update found!';
|
||||
case UpdateCheckStatus.error:
|
||||
return 'Unable to check updates';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runUpdateCheck() async {
|
||||
// Allow the rain animation to play for a short time before completing.
|
||||
await Future.delayed(const Duration(milliseconds: 900));
|
||||
|
||||
try {
|
||||
final info =
|
||||
await (widget.checkForUpdates?.call() ??
|
||||
Future.value(
|
||||
AppUpdateInfo(
|
||||
currentBuildNumber: '',
|
||||
latestVersion: null,
|
||||
isUpdateAvailable: false,
|
||||
isForceUpdate: false,
|
||||
),
|
||||
));
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_info = info;
|
||||
_status = info.isUpdateAvailable
|
||||
? UpdateCheckStatus.updateFound
|
||||
: UpdateCheckStatus.upToDate;
|
||||
});
|
||||
|
||||
// If an update is found, show the update dialog from here so it is
|
||||
// guaranteed to appear even if the parent wrapper isn't ready.
|
||||
if (info.isUpdateAvailable && !_hasShownUpdateDialog) {
|
||||
_hasShownUpdateDialog = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
await _showM3Dialog(
|
||||
context: context,
|
||||
barrierDismissible: !info.isForceUpdate,
|
||||
builder: (_) => UpdateDialog(info: info),
|
||||
);
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
// Let the UI show the final status briefly before continuing.
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onCompleted?.call(info);
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = UpdateCheckStatus.error;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onCompleted?.call(null);
|
||||
});
|
||||
}
|
||||
|
||||
// Brief pause to let user read the final state.
|
||||
await Future.delayed(const Duration(milliseconds: 850));
|
||||
if (!mounted) return;
|
||||
|
||||
// If no completion callback is provided, try to close this screen.
|
||||
if (widget.onCompleted == null) {
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<T?> _showM3Dialog<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return showGeneralDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierColor: Colors.black54,
|
||||
transitionDuration: M3Motion.standard,
|
||||
pageBuilder: (context, animation, secondaryAnimation) => builder(context),
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||||
// Use a Material-3–style fade-through transition.
|
||||
return _M3DialogFadeThrough(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
setState(() {
|
||||
if (_rng.nextDouble() < 0.3 || _drops.isEmpty) {
|
||||
// Keep at least a couple drops near the label so the rain looks like it
|
||||
// is actually hitting the text.
|
||||
final labelTop = rainHeight - 8;
|
||||
final dropsNearLabel = _drops.where((d) => d.y > labelTop - 14).length;
|
||||
final neededDrops = max(0, 2 - dropsNearLabel);
|
||||
for (var i = 0; i < neededDrops; i++) {
|
||||
_drops.add(
|
||||
_Drop(
|
||||
col: _rng.nextInt(cols),
|
||||
y: labelTop - 10 - _rng.nextDouble() * 8,
|
||||
speed: _rng.nextDouble() * (maxSpeed - minSpeed) + minSpeed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add occasional fresh drops from the top.
|
||||
if (_drops.length < minDrops || _rng.nextDouble() < 0.2) {
|
||||
_drops.add(
|
||||
_Drop(
|
||||
col: _rng.nextInt(cols),
|
||||
y: 0.0,
|
||||
speed: _rng.nextDouble() * 4 + 2,
|
||||
speed: _rng.nextDouble() * (maxSpeed - minSpeed) + minSpeed,
|
||||
),
|
||||
);
|
||||
}
|
||||
_drops.removeWhere((d) => d.y > MediaQuery.of(context).size.height);
|
||||
|
||||
// Remove drops once they reach the label area so they don't fall below.
|
||||
_drops.removeWhere((d) => d.y > labelTop);
|
||||
|
||||
// Update drops and trigger label glow when they reach the label area.
|
||||
var glowTriggered = false;
|
||||
for (final d in _drops) {
|
||||
d.y += d.speed;
|
||||
if (!glowTriggered && d.y > labelTop - 4) {
|
||||
glowTriggered = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If glow triggered, emit sparkles around the label.
|
||||
if (glowTriggered) {
|
||||
_labelGlow = 1.0;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
_sparks.add(
|
||||
_Spark(
|
||||
x: _rng.nextDouble() * cloudWidth,
|
||||
y: rainHeight + 12,
|
||||
size: _rng.nextDouble() * 4 + 2,
|
||||
life: 12,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// decay glow + update sparks
|
||||
_labelGlow = (_labelGlow - 0.06).clamp(0.0, 1.0);
|
||||
_sparks.removeWhere((s) => s.life <= 0);
|
||||
for (final s in _sparks) {
|
||||
s.life -= 1;
|
||||
s.y -= 0.8;
|
||||
s.alpha = s.life / 12.0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -46,6 +247,7 @@ class _UpdateCheckingScreenState extends State<UpdateCheckingScreen> {
|
|||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_cloudController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -55,31 +257,120 @@ class _UpdateCheckingScreenState extends State<UpdateCheckingScreen> {
|
|||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: cloudWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Hero(
|
||||
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: 80, width: 80),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: CustomPaint(
|
||||
size: Size.infinite,
|
||||
painter: _BinaryRainPainter(_drops, cols, cs.primary),
|
||||
child: Image.asset(
|
||||
'assets/tasq_ico.png',
|
||||
height: 74,
|
||||
width: 74,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Checking for updates...',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: cs.onSurface.withAlpha((0.75 * 255).round()),
|
||||
),
|
||||
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: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user