diff --git a/lib/screens/admin/app_update_screen.dart b/lib/screens/admin/app_update_screen.dart index 4abbe85b..6579c50b 100644 --- a/lib/screens/admin/app_update_screen.dart +++ b/lib/screens/admin/app_update_screen.dart @@ -14,10 +14,10 @@ import '../../utils/snackbar.dart'; import '../../widgets/app_page_header.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 -/// so that only the current entry remains. +/// Admin-only web page for uploading a new APK and associated metadata. +/// After the APK is uploaded to the "apk_updates" bucket the `app_versions` +/// table is updated and any older rows are removed so only the current entry +/// remains. class AppUpdateScreen extends ConsumerStatefulWidget { const AppUpdateScreen({super.key}); @@ -36,25 +36,22 @@ class _AppUpdateScreenState extends ConsumerState { 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. - Uint8List? _apkBytes; String? _apkName; bool _isUploading = false; - double? _progress; // null => indeterminate, otherwise 0..1 - double _realProgress = 0.0; // actual numeric progress for display (0..1) + double? _progress; + double _realProgress = 0.0; String? _eta; final List _logs = []; Timer? _progressTimer; Timer? _startDelayTimer; String? _error; + bool _showLogs = false; + bool _isSuccess = false; @override void initState() { super.initState(); - // Always have a controller so web edits do not reset when the widget rebuilds. _setQuillController(quill.QuillController.basic()); _loadCurrent(); } @@ -102,20 +99,58 @@ class _AppUpdateScreenState extends ConsumerState { } Widget _buildToolbarIcon({ - required IconData icon, + required IconData? icon, + String? label, required String tooltip, required bool isActive, required VoidCallback onPressed, }) { final theme = Theme.of(context); + final color = isActive + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withAlpha((0.75 * 255).round()); + + if (label != null) { + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(6), + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: isActive + ? BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ) + : null, + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } + return IconButton( tooltip: tooltip, icon: Icon(icon), - color: isActive - ? theme.colorScheme.primary - : theme.colorScheme.onSurface.withAlpha((0.75 * 255).round()), + color: color, onPressed: onPressed, - splashRadius: 20, + style: isActive + ? IconButton.styleFrom( + backgroundColor: theme.colorScheme.primaryContainer, + ) + : null, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(6), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), ); } @@ -124,15 +159,11 @@ class _AppUpdateScreenState extends ConsumerState { 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) { final rowList = rows as List?; Version? best; @@ -160,28 +191,7 @@ class _AppUpdateScreenState extends ConsumerState { 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( - document: doc, - selection: const TextSelection.collapsed(offset: 0), - ); - } else { - final doc = quill.Document()..insert(0, rn.toString()); - quillController = quill.QuillController( - document: doc, - selection: const TextSelection.collapsed(offset: 0), - ); - } - } catch (_) { - final doc = quill.Document()..insert(0, rn.toString()); - quillController = quill.QuillController( - document: doc, - selection: const TextSelection.collapsed(offset: 0), - ); - } + quillController = _parseReleaseNotes(rn); } } } else if (rows is Map) { @@ -189,47 +199,46 @@ class _AppUpdateScreenState extends ConsumerState { 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( - document: doc, - selection: const TextSelection.collapsed(offset: 0), - ); - } else { - final doc = quill.Document()..insert(0, rn.toString()); - quillController = quill.QuillController( - document: doc, - selection: const TextSelection.collapsed(offset: 0), - ); - } - } catch (_) { - final doc = quill.Document()..insert(0, rn.toString()); - quillController = quill.QuillController( - document: doc, - selection: const TextSelection.collapsed(offset: 0), - ); - } + quillController = _parseReleaseNotes(rn.toString()); } if (mounted) { setState(() { _existingVersion = existingVersion; - if (versionText != null) { - _versionController.text = versionText; - } - if (minVersionText != null) { - _minController.text = minVersionText; - } - if (quillController != null) { - _setQuillController(quillController); - } + if (versionText != null) _versionController.text = versionText; + if (minVersionText != null) _minController.text = minVersionText; + if (quillController != null) _setQuillController(quillController); }); } } catch (_) {} } + quill.QuillController? _parseReleaseNotes(String rn) { + if (rn.trim().isEmpty) return null; + try { + final parsed = jsonDecode(rn); + if (parsed is List) { + final doc = quill.Document.fromJson(parsed); + return quill.QuillController( + document: doc, + selection: const TextSelection.collapsed(offset: 0), + ); + } else { + final doc = quill.Document()..insert(0, rn.toString()); + return quill.QuillController( + document: doc, + selection: const TextSelection.collapsed(offset: 0), + ); + } + } catch (_) { + final doc = quill.Document()..insert(0, rn.toString()); + return quill.QuillController( + document: doc, + selection: const TextSelection.collapsed(offset: 0), + ); + } + } + @override void dispose() { _versionController.dispose(); @@ -249,6 +258,8 @@ class _AppUpdateScreenState extends ConsumerState { setState(() { _apkBytes = result.files.single.bytes; _apkName = result.files.single.name; + _isSuccess = false; + _error = null; }); } } @@ -334,33 +345,30 @@ class _AppUpdateScreenState extends ConsumerState { setState(() { _isUploading = true; - _progress = null; // show indeterminate while we attempt to start + _isSuccess = false; + _progress = null; _eta = null; _logs.clear(); _error = null; + _showLogs = false; }); try { final client = Supabase.instance.client; - // ensure the user is authenticated (browser uploads require correct auth/CORS) final user = client.auth.currentUser; _logs.add('Current user: ${user?.id ?? 'anonymous'}'); if (user == null) { throw Exception('Not signed in. Please sign in to perform uploads.'); } - // Use a deterministic object name to avoid accidental nesting final filename = '${DateTime.now().millisecondsSinceEpoch}_$_apkName'; final path = filename; _logs.add('Starting upload to bucket apk_updates, path: $path'); final stopwatch = Stopwatch()..start(); - // Show an indeterminate bar briefly while the upload is negotiating; - // after a short delay, switch to a determinate (fake) progress based on - // the payload size so the UI feels responsive. + final estimatedSeconds = (_apkBytes!.length / 250000).clamp(1, 30); _startDelayTimer?.cancel(); _startDelayTimer = Timer(const Duration(milliseconds: 700), () { - // keep the bar indeterminate, but start updating the numeric progress setState(() { _progress = null; _realProgress = 0.0; @@ -379,8 +387,6 @@ class _AppUpdateScreenState extends ConsumerState { }); }); - // Upload can occasionally stall on web; enforce a timeout so the UI - // shows an error instead of hanging at 95%. final uploadRes = await client.storage .from('apk_updates') .uploadBinary( @@ -407,14 +413,13 @@ class _AppUpdateScreenState extends ConsumerState { setState(() { _realProgress = 0.95; }); - // retrieve public URL; various SDK versions return different structures + dynamic urlRes = client.storage.from('apk_updates').getPublicUrl(path); _logs.add('Raw getPublicUrl response: ${urlRes.runtimeType} - $urlRes'); String url; if (urlRes is String) { url = urlRes; } else if (urlRes is Map) { - // supabase responses vary by SDK version if (urlRes['publicUrl'] is String) { url = urlRes['publicUrl'] as String; } else if (urlRes['data'] is String) { @@ -437,7 +442,7 @@ class _AppUpdateScreenState extends ConsumerState { ); } _logs.add('Public URL: $url'); - // upsert new version in a single statement + await client.from('app_versions').upsert({ 'version_code': vcode, 'min_version_required': minReq, @@ -445,15 +450,13 @@ class _AppUpdateScreenState extends ConsumerState { 'release_notes': notes, }, onConflict: 'version_code'); await client.from('app_versions').delete().neq('version_code', vcode); + setState(() { _realProgress = 1.0; _progress = 1.0; + _isSuccess = true; + _existingVersion = vcode; }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Version saved successfully')), - ); - } } catch (e) { _logs.add('Error during upload: $e'); setState(() { @@ -470,6 +473,22 @@ class _AppUpdateScreenState extends ConsumerState { } } + String get _fileSizeLabel { + if (_apkBytes == null) return ''; + final bytes = _apkBytes!.length; + if (bytes >= 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / 1024).toStringAsFixed(0)} KB'; + } + + String get _uploadStageLabel { + if (_realProgress < 0.05) return 'Preparing upload…'; + if (_realProgress < 0.95) return 'Uploading APK…'; + if (_realProgress < 1.0) return 'Saving version info…'; + return 'Complete'; + } + @override Widget build(BuildContext context) { if (!kIsWeb) { @@ -478,6 +497,9 @@ class _AppUpdateScreenState extends ConsumerState { ); } + final theme = Theme.of(context); + final cs = theme.colorScheme; + return Scaffold( body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -490,426 +512,674 @@ class _AppUpdateScreenState extends ConsumerState { child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 800), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(16.0), child: Form( key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - controller: _versionController, - decoration: const InputDecoration( - labelText: 'Version (e.g. 1.2.3)', - ), - keyboardType: TextInputType.text, - validator: (v) => - (v == null || v.isEmpty) ? 'Required' : null, - ), - TextFormField( - controller: _minController, - decoration: const InputDecoration( - labelText: 'Min Version (e.g. 0.1.1)', - ), - keyboardType: TextInputType.text, - validator: (v) => - (v == null || v.isEmpty) ? 'Required' : null, - ), - // Release notes: use Quill rich editor when available (web). - if (_quillController != null || kIsWeb) ...[ - 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( - context: context, - builder: (context) { - final ctrl = TextEditingController( - text: existing?.value as String?, - ); - return AlertDialog( - title: const Text('Insert link'), - content: TextField( - controller: ctrl, - decoration: - const InputDecoration( - labelText: 'URL', - ), - keyboardType: TextInputType.url, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of( - context, - ).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of( - context, - ).pop(ctrl.text.trim()); - }, - child: const Text('OK'), - ), - ], - ); - }, - ); - if (url == null) return; - if (url.isEmpty) { - _toggleAttribute( - quill.Attribute.link, - ); - } else { - _quillController!.formatSelection( - quill.LinkAttribute(url), - ); - } - }, - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - height: 200, - child: quill.QuillEditor.basic( - controller: _quillController!, - focusNode: _quillFocusNode, - scrollController: _quillScrollController, - config: quill.QuillEditorConfig( - scrollable: true, - padding: EdgeInsets.zero, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Section 1: Version Info ─────────────────────── + _SectionCard( + label: 'Version Info', + icon: Icons.tag_rounded, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: _versionController, + decoration: InputDecoration( + labelText: 'Version', + hintText: '1.2.3', + prefixIcon: const Icon(Icons.tag_rounded), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), ), ), + onChanged: (_) => setState(() => _isSuccess = false), + validator: (v) => + (v == null || v.isEmpty) ? 'Required' : null, ), - 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'), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _minController, + decoration: InputDecoration( + labelText: 'Min Version Required', + hintText: '0.1.1', + prefixIcon: const Icon(Icons.low_priority_rounded), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), ), - if (_isImprovingNotes) ...[ - const SizedBox(width: 12), - Text( - 'Improving...', - style: Theme.of( - context, - ).textTheme.bodySmall, - ), - ], - ], + ), + onChanged: (_) => setState(() => _isSuccess = false), + validator: (v) => + (v == null || v.isEmpty) ? 'Required' : null, ), - ], - ), + ), + ], ), ), - ] else ...[ - TextFormField( - controller: _notesController, - decoration: const InputDecoration( - labelText: 'Release Notes', - ), - maxLines: 3, + const SizedBox(height: 12), + + // ── Section 2: Release Notes ────────────────────── + _SectionCard( + label: 'Release Notes', + icon: Icons.notes_rounded, + child: _buildReleaseNotesSection(context, cs, theme), ), - ], - const SizedBox(height: 12), - Row( - children: [ - ElevatedButton( - onPressed: _isUploading ? null : _pickApk, - child: const Text('Select APK'), - ), - const SizedBox(width: 8), - Expanded(child: Text(_apkName ?? 'no file chosen')), + const SizedBox(height: 12), + + // ── Section 3: APK File ─────────────────────────── + _SectionCard( + label: 'APK Package', + icon: Icons.android_rounded, + child: _buildApkDropZone(cs, theme), + ), + const SizedBox(height: 16), + + // ── Upload Progress ─────────────────────────────── + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: _isUploading + ? _buildProgressCard(cs, theme) + : const SizedBox.shrink(), + ), + + // ── Error Container ─────────────────────────────── + if (_error != null) ...[ + _buildErrorCard(cs, theme), + const SizedBox(height: 12), ], - ), - const SizedBox(height: 20), - if (_isUploading) ...[ - // keep the animated indeterminate bar while showing the - // numeric progress percentage on top (smoothly animated). - Stack( - alignment: Alignment.center, - children: [ - SizedBox( - width: double.infinity, - child: LinearProgressIndicator(value: _progress), - ), - // Smoothly animate the displayed percentage so updates feel fluid - TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: _realProgress), - duration: const Duration(milliseconds: 300), - builder: (context, value, child) { - final pct = (value * 100).clamp(0.0, 100.0); - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surface.withAlpha(153), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${pct.toStringAsFixed(pct >= 10 ? 0 : 1)}% ', - style: Theme.of( - context, - ).textTheme.bodyMedium, - ), - ); - }, - ), - ], - ), - if (_eta != null) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text('ETA: $_eta'), - ), - const SizedBox(height: 12), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 150), - child: ListView( - shrinkWrap: true, - children: _logs.map((l) => Text(l)).toList(), + + // ── Success Banner ──────────────────────────────── + if (_isSuccess && !_isUploading) ...[ + _buildSuccessBanner(cs, theme), + const SizedBox(height: 12), + ], + + // ── Publish Button ──────────────────────────────── + SizedBox( + width: double.infinity, + height: 52, + child: FilledButton.icon( + onPressed: _isUploading ? null : _submit, + icon: _isUploading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Icon(Icons.rocket_launch_rounded), + label: Text(_isUploading ? 'Publishing…' : 'Publish Update'), ), ), - const SizedBox(height: 12), ], - if (_error != null) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.error_outline, color: Colors.red), - const SizedBox(width: 8), - Expanded( - child: Text( - _error!, - style: const TextStyle(color: Colors.red), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - OutlinedButton( - onPressed: _isUploading - ? null - : () { - setState(() { - _error = null; - }); - _submit(); - }, - child: const Text('Retry'), - ), - const SizedBox(width: 8), - TextButton( - onPressed: _isUploading - ? null - : () { - setState(() { - _error = null; - _apkBytes = null; - _apkName = null; - _progress = null; - _realProgress = 0; - _eta = null; - _logs.clear(); - }); - }, - child: const Text('Reset'), - ), - ], - ), - const SizedBox(height: 12), - ], - ElevatedButton( - onPressed: _isUploading ? null : _submit, - child: _isUploading - ? const CircularProgressIndicator() - : const Text('Save'), - ), - ], + ), ), ), ), ), ), + ], + ), + ); + } + + Widget _buildReleaseNotesSection( + BuildContext context, + ColorScheme cs, + ThemeData theme, + ) { + if (_quillController == null && !kIsWeb) { + return TextFormField( + controller: _notesController, + decoration: InputDecoration( + labelText: 'Release Notes', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + maxLines: 5, + ); + } + + return GeminiAnimatedBorder( + isProcessing: _isImprovingNotes, + borderRadius: 12, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Formatting toolbar + Container( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 2), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withAlpha(120), + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Wrap( + spacing: 2, + 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), + ), + const VerticalDivider(width: 12, indent: 4, endIndent: 4), + _buildToolbarIcon( + icon: null, + label: 'H1', + tooltip: 'Heading 1', + isActive: _currentHeaderLevel() == 1, + onPressed: () => _setHeader(1), + ), + _buildToolbarIcon( + icon: null, + label: 'H2', + tooltip: 'Heading 2', + isActive: _currentHeaderLevel() == 2, + onPressed: () => _setHeader(2), + ), + _buildToolbarIcon( + icon: null, + label: 'H3', + tooltip: 'Heading 3', + isActive: _currentHeaderLevel() == 3, + onPressed: () => _setHeader(3), + ), + const VerticalDivider(width: 12, indent: 4, endIndent: 4), + _buildToolbarIcon( + icon: Icons.link, + tooltip: 'Link', + isActive: _quillController + ?.getSelectionStyle() + .attributes + .containsKey(quill.Attribute.link.key) == + true, + onPressed: _insertLink, + ), + ], + ), + ), + const Divider(height: 1), + // Editor area + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: SizedBox( + height: 200, + child: quill.QuillEditor.basic( + controller: _quillController!, + focusNode: _quillFocusNode, + scrollController: _quillScrollController, + config: quill.QuillEditorConfig( + scrollable: true, + padding: EdgeInsets.zero, + ), + ), + ), + ), + // AI Improve footer + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Row( + children: [ + const Spacer(), + FilledButton.tonalIcon( + onPressed: _isImprovingNotes + ? null + : () => _improveReleaseNotesWithGemini(context), + icon: _isImprovingNotes + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome, size: 18), + label: Text(_isImprovingNotes ? 'Improving…' : 'Improve with AI'), + ), + ], + ), + ), + ], ), ), + ); + } + + void _insertLink() async { + final selection = _quillController!.selection; + if (selection.isCollapsed) return; + final current = _quillController!.getSelectionStyle(); + final existing = current.attributes[quill.Attribute.link.key]; + final url = await showDialog( + context: context, + builder: (context) { + final ctrl = TextEditingController( + text: existing?.value as String?, + ); + return AlertDialog( + title: const Text('Insert link'), + content: TextField( + controller: ctrl, + decoration: InputDecoration( + labelText: 'URL', + prefixIcon: const Icon(Icons.link), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + keyboardType: TextInputType.url, + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(ctrl.text.trim()), + child: const Text('Insert'), + ), + ], + ); + }, + ); + if (url == null) return; + if (url.isEmpty) { + _toggleAttribute(quill.Attribute.link); + } else { + _quillController!.formatSelection(quill.LinkAttribute(url)); + } + } + + Widget _buildApkDropZone(ColorScheme cs, ThemeData theme) { + final hasFile = _apkBytes != null && _apkName != null; + + return InkWell( + onTap: _isUploading ? null : _pickApk, + borderRadius: BorderRadius.circular(12), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: hasFile ? cs.primary : cs.outlineVariant, + width: hasFile ? 1.5 : 1, + ), + color: hasFile + ? cs.primaryContainer.withAlpha(60) + : cs.surfaceContainerHighest.withAlpha(60), ), + child: hasFile + ? Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.android_rounded, + color: cs.onPrimaryContainer, + size: 28, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _apkName!, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + _fileSizeLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + TextButton.icon( + onPressed: _isUploading ? null : _pickApk, + icon: const Icon(Icons.swap_horiz_rounded, size: 18), + label: const Text('Change'), + ), + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cloud_upload_outlined, + size: 48, + color: cs.primary.withAlpha(180), + ), + const SizedBox(height: 12), + Text( + 'Select APK file', + style: theme.textTheme.titleSmall?.copyWith( + color: cs.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + 'Tap to browse — .apk files only', + style: theme.textTheme.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + Widget _buildProgressCard(ColorScheme cs, ThemeData theme) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Card( + elevation: 0, + color: cs.surfaceContainerHighest, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.upload_rounded, size: 18, color: cs.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + _uploadStageLabel, + style: theme.textTheme.labelLarge?.copyWith( + color: cs.onSurface, + ), + ), + ), + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: _realProgress), + duration: const Duration(milliseconds: 300), + builder: (context, value, _) { + final pct = (value * 100).clamp(0.0, 100.0); + return Text( + '${pct.toStringAsFixed(pct >= 10 ? 0 : 1)}%', + style: theme.textTheme.labelLarge?.copyWith( + color: cs.primary, + fontWeight: FontWeight.bold, + ), + ); + }, + ), + ], + ), + const SizedBox(height: 10), + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: _realProgress), + duration: const Duration(milliseconds: 300), + builder: (context, value, _) => LinearProgressIndicator( + value: _progress == null ? null : value, + borderRadius: BorderRadius.circular(4), + ), + ), + if (_eta != null) ...[ + const SizedBox(height: 6), + Text( + 'Estimated time remaining: $_eta', + style: theme.textTheme.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ], + if (_logs.isNotEmpty) ...[ + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => setState(() => _showLogs = !_showLogs), + icon: Icon( + _showLogs + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: 16, + ), + label: Text(_showLogs ? 'Hide details' : 'Show details'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + if (_showLogs) + Container( + margin: const EdgeInsets.only(top: 4), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(8), + ), + constraints: const BoxConstraints(maxHeight: 120), + child: ListView( + shrinkWrap: true, + children: _logs + .map( + (l) => Text( + l, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: cs.onSurfaceVariant, + ), + ), + ) + .toList(), + ), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildErrorCard(ColorScheme cs, ThemeData theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline_rounded, color: cs.onErrorContainer, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + _error!, + style: theme.textTheme.bodySmall?.copyWith( + color: cs.onErrorContainer, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Row( + children: [ + FilledButton.tonal( + onPressed: _isUploading + ? null + : () { + setState(() => _error = null); + _submit(); + }, + child: const Text('Retry'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: _isUploading + ? null + : () { + setState(() { + _error = null; + _apkBytes = null; + _apkName = null; + _progress = null; + _realProgress = 0; + _eta = null; + _logs.clear(); + }); + }, + child: const Text('Reset'), + ), + ], + ), + const SizedBox(height: 8), ], - ), - ); + ); + } + + Widget _buildSuccessBanner(ColorScheme cs, ThemeData theme) { + final vcode = _versionController.text.trim(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.check_circle_rounded, color: cs.onPrimaryContainer, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Version $vcode published successfully', + style: theme.textTheme.bodyMedium?.copyWith( + color: cs.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} + +/// A labeled card section used to group related form fields. +class _SectionCard extends StatelessWidget { + const _SectionCard({ + required this.label, + required this.icon, + required this.child, + }); + + final String label; + final IconData icon; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: cs.outlineVariant.withAlpha(120)), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: cs.primary), + const SizedBox(width: 6), + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: cs.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 14), + child, + ], + ), + ), + ); } }