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> {
|
class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
|
||||||
bool _done = false;
|
bool _done = false;
|
||||||
AppUpdateInfo? _info;
|
|
||||||
|
|
||||||
@override
|
Future<AppUpdateInfo> _checkForUpdates() =>
|
||||||
void initState() {
|
AppUpdateService.instance.checkForUpdate();
|
||||||
super.initState();
|
|
||||||
_performCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
_info = await AppUpdateService.instance.checkForUpdate();
|
await showDialog(
|
||||||
} catch (e) {
|
context: dialogContext,
|
||||||
debugPrint('update check failed: $e');
|
barrierDismissible: !(info?.isForceUpdate ?? false),
|
||||||
}
|
builder: (_) => UpdateDialog(info: info!),
|
||||||
if (mounted) {
|
|
||||||
setState(() => _done = true);
|
|
||||||
if (_info?.isUpdateAvailable == true) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
showDialog(
|
|
||||||
context: globalNavigatorKey.currentContext!,
|
|
||||||
barrierDismissible: !_info!.isForceUpdate,
|
|
||||||
builder: (_) => UpdateDialog(info: _info!),
|
|
||||||
);
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// If the dialog fails (rare), continue to the next screen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_done = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!_done) {
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
darkTheme: AppTheme.dark(),
|
darkTheme: AppTheme.dark(),
|
||||||
themeMode: ThemeMode.system,
|
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:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
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
|
/// 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"
|
/// associated metadata. After the APK is uploaded to the "apk_updates"
|
||||||
/// bucket the `app_versions` table is updated and any older rows are removed
|
/// 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 _minController = TextEditingController();
|
||||||
final _notesController = TextEditingController();
|
final _notesController = TextEditingController();
|
||||||
quill.QuillController? _quillController;
|
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
|
// We store release notes as plain text for compatibility; existing
|
||||||
// Quill delta JSON in the database will be parsed and displayed by
|
// Quill delta JSON in the database will be parsed and displayed by
|
||||||
// the Android update dialog renderer.
|
// the Android update dialog renderer.
|
||||||
|
|
@ -44,13 +53,83 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Always have a controller so web edits do not reset when the widget rebuilds.
|
||||||
|
_setQuillController(quill.QuillController.basic());
|
||||||
_loadCurrent();
|
_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 {
|
Future<void> _loadCurrent() async {
|
||||||
try {
|
try {
|
||||||
final client = Supabase.instance.client;
|
final client = Supabase.instance.client;
|
||||||
final rows = await client.from('app_versions').select().maybeSingle();
|
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
|
// when using text versions we can't rely on server-side ordering; instead
|
||||||
// parse locally and choose the greatest semantic version.
|
// parse locally and choose the greatest semantic version.
|
||||||
if (rows is List) {
|
if (rows is List) {
|
||||||
|
|
@ -75,46 +154,78 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bestRow != null) {
|
if (bestRow != null) {
|
||||||
_versionController.text = bestRow['version_code']?.toString() ?? '';
|
existingVersion = bestRow['version_code']?.toString();
|
||||||
_minController.text =
|
versionText = bestRow['version_code']?.toString() ?? '';
|
||||||
bestRow['min_version_required']?.toString() ?? '';
|
minVersionText = bestRow['min_version_required']?.toString() ?? '';
|
||||||
final rn = bestRow['release_notes'] ?? '';
|
final rn = bestRow['release_notes'] ?? '';
|
||||||
if (rn is String && rn.trim().isNotEmpty) {
|
if (rn is String && rn.trim().isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final parsed = jsonDecode(rn);
|
final parsed = jsonDecode(rn);
|
||||||
if (parsed is List) {
|
if (parsed is List) {
|
||||||
final doc = quill.Document.fromJson(parsed);
|
final doc = quill.Document.fromJson(parsed);
|
||||||
_quillController = quill.QuillController(
|
quillController = quill.QuillController(
|
||||||
document: doc,
|
document: doc,
|
||||||
selection: const TextSelection.collapsed(offset: 0),
|
selection: const TextSelection.collapsed(offset: 0),
|
||||||
);
|
);
|
||||||
} else {
|
} 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 (_) {
|
} 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>) {
|
} else if (rows is Map<String, dynamic>) {
|
||||||
_versionController.text = rows['version_code']?.toString() ?? '';
|
existingVersion = rows['version_code']?.toString();
|
||||||
_minController.text = rows['min_version_required']?.toString() ?? '';
|
versionText = rows['version_code']?.toString() ?? '';
|
||||||
|
minVersionText = rows['min_version_required']?.toString() ?? '';
|
||||||
final rn = rows['release_notes'] ?? '';
|
final rn = rows['release_notes'] ?? '';
|
||||||
try {
|
try {
|
||||||
final parsed = jsonDecode(rn);
|
final parsed = jsonDecode(rn);
|
||||||
if (parsed is List) {
|
if (parsed is List) {
|
||||||
final doc = quill.Document.fromJson(parsed);
|
final doc = quill.Document.fromJson(parsed);
|
||||||
_quillController = quill.QuillController(
|
quillController = quill.QuillController(
|
||||||
document: doc,
|
document: doc,
|
||||||
selection: const TextSelection.collapsed(offset: 0),
|
selection: const TextSelection.collapsed(offset: 0),
|
||||||
);
|
);
|
||||||
} else {
|
} 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 (_) {
|
} 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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,6 +234,8 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
_versionController.dispose();
|
_versionController.dispose();
|
||||||
_minController.dispose();
|
_minController.dispose();
|
||||||
_notesController.dispose();
|
_notesController.dispose();
|
||||||
|
_quillFocusNode.dispose();
|
||||||
|
_quillScrollController.dispose();
|
||||||
super.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 {
|
Future<void> _submit() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
if (_apkBytes == null || _apkName == null) {
|
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
|
final uploadRes = await client.storage
|
||||||
.from('apk_updates')
|
.from('apk_updates')
|
||||||
.uploadBinary(
|
.uploadBinary(
|
||||||
|
|
@ -211,6 +389,10 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
upsert: true,
|
upsert: true,
|
||||||
contentType: 'application/vnd.android.package-archive',
|
contentType: 'application/vnd.android.package-archive',
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.timeout(
|
||||||
|
const Duration(minutes: 3),
|
||||||
|
onTimeout: () => throw Exception('Upload timed out. Please retry.'),
|
||||||
);
|
);
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
_logs.add('Upload finished (took ${stopwatch.elapsed.inSeconds}s)');
|
_logs.add('Upload finished (took ${stopwatch.elapsed.inSeconds}s)');
|
||||||
|
|
@ -273,7 +455,11 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logs.add('Error during upload: $e');
|
_logs.add('Error during upload: $e');
|
||||||
setState(() => _error = e.toString());
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_progress = null;
|
||||||
|
_realProgress = 0;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
_startDelayTimer?.cancel();
|
_startDelayTimer?.cancel();
|
||||||
_startDelayTimer = null;
|
_startDelayTimer = null;
|
||||||
|
|
@ -328,16 +514,252 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
(v == null || v.isEmpty) ? 'Required' : null,
|
(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) ...[
|
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),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
child: quill.QuillEditor.basic(
|
child: quill.QuillEditor.basic(
|
||||||
controller:
|
controller: _quillController!,
|
||||||
_quillController ??
|
focusNode: _quillFocusNode,
|
||||||
quill.QuillController.basic(),
|
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 ...[
|
] 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 '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.
|
/// Simple binary rain animation with a label for the update check splash.
|
||||||
class UpdateCheckingScreen extends StatefulWidget {
|
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
|
@override
|
||||||
State<UpdateCheckingScreen> createState() => _UpdateCheckingScreenState();
|
State<UpdateCheckingScreen> createState() => _UpdateCheckingScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UpdateCheckingScreenState extends State<UpdateCheckingScreen> {
|
class _UpdateCheckingScreenState extends State<UpdateCheckingScreen>
|
||||||
static const int cols = 20;
|
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<_Drop> _drops = [];
|
||||||
|
final List<_Spark> _sparks = [];
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
final Random _rng = Random();
|
final Random _rng = Random();
|
||||||
|
|
||||||
|
late final AnimationController _cloudController;
|
||||||
|
double _labelGlow = 0.0; // 0..1
|
||||||
|
UpdateCheckStatus _status = UpdateCheckStatus.checking;
|
||||||
|
bool _hasShownUpdateDialog = false;
|
||||||
|
AppUpdateInfo? _info;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_cloudController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
)..repeat(reverse: true);
|
||||||
|
|
||||||
_timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
_timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
||||||
_tick();
|
_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() {
|
void _tick() {
|
||||||
setState(() {
|
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(
|
_drops.add(
|
||||||
_Drop(
|
_Drop(
|
||||||
col: _rng.nextInt(cols),
|
col: _rng.nextInt(cols),
|
||||||
y: 0.0,
|
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) {
|
for (final d in _drops) {
|
||||||
d.y += d.speed;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
_cloudController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,31 +257,120 @@ class _UpdateCheckingScreenState extends State<UpdateCheckingScreen> {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: cs.surface,
|
backgroundColor: cs.surface,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: cloudWidth,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 40),
|
SizedBox(
|
||||||
Hero(
|
height: 120,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
child: Hero(
|
||||||
tag: 'tasq-logo',
|
tag: 'tasq-logo',
|
||||||
child: Image.asset('assets/tasq_ico.png', height: 80, width: 80),
|
child: Image.asset(
|
||||||
),
|
'assets/tasq_ico.png',
|
||||||
const SizedBox(height: 24),
|
height: 74,
|
||||||
Expanded(
|
width: 74,
|
||||||
child: CustomPaint(
|
|
||||||
size: Size.infinite,
|
|
||||||
painter: _BinaryRainPainter(_drops, cols, cs.primary),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
Text(
|
Positioned(
|
||||||
'Checking for updates...',
|
bottom: 0,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
child: AnimatedBuilder(
|
||||||
color: cs.onSurface.withAlpha((0.75 * 255).round()),
|
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});
|
_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 {
|
class _BinaryRainPainter extends CustomPainter {
|
||||||
static const double fontSize = 16;
|
static const double fontSize = 16;
|
||||||
final List<_Drop> drops;
|
final List<_Drop> drops;
|
||||||
final int cols;
|
final int cols;
|
||||||
final Color textColor;
|
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
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
// paint variable not needed when drawing text
|
|
||||||
final textStyle = TextStyle(
|
final textStyle = TextStyle(
|
||||||
color: textColor,
|
color: textColor,
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
|
|
@ -120,8 +466,37 @@ class _BinaryRainPainter extends CustomPainter {
|
||||||
final y = d.y;
|
final y = d.y;
|
||||||
tp.paint(canvas, Offset(x, 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
|
@override
|
||||||
bool shouldRepaint(covariant _BinaryRainPainter old) => true;
|
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 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
// We render Quill deltas here without depending on the flutter_quill editor
|
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||||
// API to avoid analyzer/API mismatches; we support common inline styles.
|
|
||||||
|
|
||||||
import '../models/app_version.dart';
|
import '../models/app_version.dart';
|
||||||
import '../services/app_update_service.dart';
|
import '../services/app_update_service.dart';
|
||||||
|
|
@ -25,6 +24,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
bool _failed = false;
|
bool _failed = false;
|
||||||
List<dynamic>? _notesDelta;
|
List<dynamic>? _notesDelta;
|
||||||
String? _notesPlain;
|
String? _notesPlain;
|
||||||
|
quill.QuillController? _notesController;
|
||||||
|
final FocusNode _notesFocusNode = FocusNode();
|
||||||
|
final ScrollController _notesScrollController = ScrollController();
|
||||||
|
|
||||||
Future<void> _startDownload() async {
|
Future<void> _startDownload() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -56,11 +58,19 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
final notes = widget.info.latestVersion?.releaseNotes ?? '';
|
final notes = widget.info.latestVersion?.releaseNotes ?? '';
|
||||||
|
|
||||||
// parse release notes into a Quill delta list if possible
|
// 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 {
|
try {
|
||||||
final parsed = jsonDecode(notes);
|
final parsed = jsonDecode(notes);
|
||||||
if (parsed is List) {
|
if (parsed is List) {
|
||||||
_notesDelta = parsed;
|
_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 {
|
} else {
|
||||||
_notesPlain = notes;
|
_notesPlain = notes;
|
||||||
}
|
}
|
||||||
|
|
@ -85,20 +95,36 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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
|
// Render release notes: prefer Quill delta if available
|
||||||
if (_notesDelta != null)
|
if (_notesController != null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 250,
|
height: 250,
|
||||||
child: SingleChildScrollView(
|
child: quill.QuillEditor.basic(
|
||||||
child: RichText(
|
controller: _notesController!,
|
||||||
text: _deltaToTextSpan(
|
focusNode: _notesFocusNode,
|
||||||
_notesDelta!,
|
scrollController: _notesScrollController,
|
||||||
Theme.of(context).textTheme.bodyMedium,
|
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 (_notesController == null &&
|
||||||
if (_notesDelta == null &&
|
|
||||||
_notesPlain != null &&
|
_notesPlain != null &&
|
||||||
_notesPlain!.isNotEmpty)
|
_notesPlain!.isNotEmpty)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|
@ -163,64 +189,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
return actions;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -231,6 +199,14 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
final parsed = jsonDecode(notes);
|
final parsed = jsonDecode(notes);
|
||||||
if (parsed is List) {
|
if (parsed is List) {
|
||||||
_notesDelta = parsed;
|
_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 {
|
} else {
|
||||||
_notesPlain = notes;
|
_notesPlain = notes;
|
||||||
}
|
}
|
||||||
|
|
@ -242,6 +218,11 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_notesFocusNode.dispose();
|
||||||
|
_notesScrollController.dispose();
|
||||||
super.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