import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import '../../services/ai_service.dart'; import '../../utils/snackbar.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/gemini_animated_text_field.dart'; /// 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}); @override ConsumerState createState() => _AppUpdateScreenState(); } class _AppUpdateScreenState extends ConsumerState { final _formKey = GlobalKey(); final _versionController = TextEditingController(); final _minController = TextEditingController(); final _notesController = TextEditingController(); quill.QuillController? _quillController; final FocusNode _quillFocusNode = FocusNode(); final ScrollController _quillScrollController = ScrollController(); bool _isImprovingNotes = false; String? _existingVersion; Uint8List? _apkBytes; String? _apkName; bool _isUploading = false; 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(); _setQuillController(quill.QuillController.basic()); _loadCurrent(); } void _setQuillController(quill.QuillController controller) { _quillController?.removeListener(_updateQuillToolbar); _quillController = controller; _quillController?.addListener(_updateQuillToolbar); } void _updateQuillToolbar() { if (mounted) setState(() {}); } void _toggleAttribute(quill.Attribute attribute) { final current = _quillController?.getSelectionStyle(); if (current?.attributes.containsKey(attribute.key) == true) { _quillController?.formatSelection(quill.Attribute.clone(attribute, null)); } else { _quillController?.formatSelection(attribute); } } int? _currentHeaderLevel() { final attrs = _quillController?.getSelectionStyle().attributes; if (attrs == null) return null; final Object? headerValue = attrs[quill.Attribute.header.key]; if (headerValue is quill.HeaderAttribute) { return headerValue.value; } if (headerValue is int) { return headerValue; } return null; } void _setHeader(int? level) { if (level == null) { _quillController?.formatSelection( quill.Attribute.clone(quill.Attribute.header, null), ); } else { _quillController?.formatSelection(quill.HeaderAttribute(level: level)); } } Widget _buildToolbarIcon({ required IconData? icon, 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: color, onPressed: onPressed, style: isActive ? IconButton.styleFrom( backgroundColor: theme.colorScheme.primaryContainer, ) : null, visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(6), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), ); } Future _loadCurrent() async { try { final client = Supabase.instance.client; final rows = await client.from('app_versions').select().maybeSingle(); String? existingVersion; String? versionText; String? minVersionText; quill.QuillController? quillController; if (rows is List) { final rowList = rows as List?; Version? best; Map? bestRow; if (rowList != null) { for (final r in rowList) { if (r is Map) { final v = r['version_code']?.toString() ?? ''; Version parsed; try { parsed = Version.parse(v); } catch (_) { continue; } if (best == null || parsed > best) { best = parsed; bestRow = r; } } } } if (bestRow != null) { existingVersion = bestRow['version_code']?.toString(); versionText = bestRow['version_code']?.toString() ?? ''; minVersionText = bestRow['min_version_required']?.toString() ?? ''; final rn = bestRow['release_notes'] ?? ''; if (rn is String && rn.trim().isNotEmpty) { quillController = _parseReleaseNotes(rn); } } } else if (rows is Map) { existingVersion = rows['version_code']?.toString(); versionText = rows['version_code']?.toString() ?? ''; minVersionText = rows['min_version_required']?.toString() ?? ''; final rn = rows['release_notes'] ?? ''; 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); }); } } 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(); _minController.dispose(); _notesController.dispose(); _quillFocusNode.dispose(); _quillScrollController.dispose(); super.dispose(); } Future _pickApk() async { final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['apk'], ); if (result != null && result.files.single.bytes != null) { setState(() { _apkBytes = result.files.single.bytes; _apkName = result.files.single.name; _isSuccess = false; _error = null; }); } } Future _improveReleaseNotesWithGemini(BuildContext context) async { final controller = _quillController; if (controller == null) return; final plain = controller.document.toPlainText().trim(); if (plain.isEmpty) { if (!context.mounted) return; showWarningSnackBar(context, 'Please enter some release notes first'); return; } setState(() { _isImprovingNotes = true; }); try { final aiService = AiService(); final from = _existingVersion?.trim(); final to = _versionController.text.trim(); final prompt = StringBuffer( 'Improve these release notes for a mobile app update. ' 'Keep the message concise, clear, and professional. ' 'The notes may include formatting (bold, italic, underline, headings, lists, code blocks, links). ' 'Do NOT change or remove existing markdown-style formatting markers. ' 'Return ONLY the improved release notes in plain text using markdown-style markers (e.g. **bold**, *italic*, `code`, # Heading, - list, [link](url)). ' 'Do not output HTML, JSON, or any extra commentary. ' 'If the input already contains markdown markers, keep them and do not rewrite them into a different format.', ); if (from != null && from.isNotEmpty && to.isNotEmpty) { prompt.write(' Update is from $from → $to.'); } else if (to.isNotEmpty) { prompt.write(' Update version: $to.'); } final improved = await aiService.enhanceText( plain, promptInstruction: prompt.toString(), ); final trimmed = improved.trim(); final docLen = controller.document.length; controller.replaceText( 0, docLen - 1, trimmed, TextSelection.collapsed(offset: trimmed.length), ); if (context.mounted) { showSuccessSnackBar(context, 'Release notes improved with Gemini'); } } catch (e) { if (context.mounted) { showErrorSnackBar(context, 'Gemini failed: $e'); } } finally { if (mounted) { setState(() { _isImprovingNotes = false; }); } } } Future _submit() async { if (!_formKey.currentState!.validate()) return; if (_apkBytes == null || _apkName == null) { setState(() => _error = 'Please select an APK file.'); return; } final vcode = _versionController.text.trim(); final minReq = _minController.text.trim(); String notes; if (_quillController != null) { notes = jsonEncode(_quillController!.document.toDelta().toJson()); } else { notes = _notesController.text; } setState(() { _isUploading = true; _isSuccess = false; _progress = null; _eta = null; _logs.clear(); _error = null; _showLogs = false; }); try { final client = Supabase.instance.client; 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.'); } final filename = '${DateTime.now().millisecondsSinceEpoch}_$_apkName'; final path = filename; _logs.add('Starting upload to bucket apk_updates, path: $path'); final stopwatch = Stopwatch()..start(); final estimatedSeconds = (_apkBytes!.length / 250000).clamp(1, 30); _startDelayTimer?.cancel(); _startDelayTimer = Timer(const Duration(milliseconds: 700), () { setState(() { _progress = null; _realProgress = 0.0; }); _progressTimer = Timer.periodic(const Duration(milliseconds: 200), (t) { final elapsed = stopwatch.elapsed.inMilliseconds / 1000.0; final pct = (elapsed / estimatedSeconds).clamp(0.0, 0.95); setState(() { _realProgress = pct; final remaining = (estimatedSeconds - elapsed).clamp( 0.0, double.infinity, ); _eta = '${remaining.toStringAsFixed(1)}s'; }); }); }); final uploadRes = await client.storage .from('apk_updates') .uploadBinary( path, _apkBytes!, fileOptions: const FileOptions( upsert: true, contentType: 'application/vnd.android.package-archive', ), ) .timeout( const Duration(minutes: 3), onTimeout: () => throw Exception('Upload timed out. Please retry.'), ); stopwatch.stop(); _logs.add('Upload finished (took ${stopwatch.elapsed.inSeconds}s)'); _logs.add('Raw upload response: ${uploadRes.runtimeType} - $uploadRes'); if (uploadRes is Map) { final Map m = uploadRes as Map; if (m.containsKey('error') && m['error'] != null) { throw Exception('upload failed: ${m['error']}'); } } setState(() { _realProgress = 0.95; }); 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) { if (urlRes['publicUrl'] is String) { url = urlRes['publicUrl'] as String; } else if (urlRes['data'] is String) { url = urlRes['data'] as String; } else if (urlRes['data'] is Map) { final d = urlRes['data'] as Map; url = (d['publicUrl'] ?? d['public_url'] ?? d['url'] ?? d['publicURL']) as String? ?? ''; } else { url = ''; } } else { url = ''; } if (url.isEmpty) { throw Exception( 'could not obtain public url, check bucket CORS and policies', ); } _logs.add('Public URL: $url'); await client.from('app_versions').upsert({ 'version_code': vcode, 'min_version_required': minReq, 'download_url': url, '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; }); } catch (e) { _logs.add('Error during upload: $e'); setState(() { _error = e.toString(); _progress = null; _realProgress = 0; }); } finally { _startDelayTimer?.cancel(); _startDelayTimer = null; _progressTimer?.cancel(); _progressTimer = null; setState(() => _isUploading = false); } } 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) { return const Center( child: Text('This page is only available on the web.'), ); } final theme = Theme.of(context); final cs = theme.colorScheme; return Scaffold( body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const AppPageHeader( title: 'App Update', subtitle: 'Upload and manage APK releases', ), Expanded( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 800), child: Form( key: _formKey, 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(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), ), ), onChanged: (_) => setState(() => _isSuccess = false), validator: (v) => (v == null || v.isEmpty) ? 'Required' : null, ), ), ], ), ), 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), // ── 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), ], // ── 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'), ), ), ], ), ), ), ), ), ), ], ), ); } 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, ], ), ), ); } }