1186 lines
41 KiB
Dart
1186 lines
41 KiB
Dart
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<AppUpdateScreen> createState() => _AppUpdateScreenState();
|
|
}
|
|
|
|
class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
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<String> _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<void> _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<String, dynamic>? bestRow;
|
|
if (rowList != null) {
|
|
for (final r in rowList) {
|
|
if (r is Map<String, dynamic>) {
|
|
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<String, dynamic>) {
|
|
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<void> _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<void> _improveReleaseNotesWithGemini(BuildContext context) async {
|
|
final controller = _quillController;
|
|
if (controller == null) return;
|
|
final plain = controller.document.toPlainText().trim();
|
|
if (plain.isEmpty) {
|
|
if (!context.mounted) return;
|
|
showWarningSnackBar(context, 'Please enter some release notes first');
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isImprovingNotes = true;
|
|
});
|
|
|
|
try {
|
|
final aiService = AiService();
|
|
final from = _existingVersion?.trim();
|
|
final to = _versionController.text.trim();
|
|
final prompt = StringBuffer(
|
|
'Improve these release notes for a mobile app update. '
|
|
'Keep the message concise, clear, and professional. '
|
|
'The notes may include formatting (bold, italic, underline, headings, lists, code blocks, links). '
|
|
'Do NOT change or remove existing markdown-style formatting markers. '
|
|
'Return ONLY the improved release notes in plain text using markdown-style markers (e.g. **bold**, *italic*, `code`, # Heading, - list, [link](url)). '
|
|
'Do not output HTML, JSON, or any extra commentary. '
|
|
'If the input already contains markdown markers, keep them and do not rewrite them into a different format.',
|
|
);
|
|
if (from != null && from.isNotEmpty && to.isNotEmpty) {
|
|
prompt.write(' Update is from $from → $to.');
|
|
} else if (to.isNotEmpty) {
|
|
prompt.write(' Update version: $to.');
|
|
}
|
|
|
|
final improved = await aiService.enhanceText(
|
|
plain,
|
|
promptInstruction: prompt.toString(),
|
|
);
|
|
|
|
final trimmed = improved.trim();
|
|
final docLen = controller.document.length;
|
|
controller.replaceText(
|
|
0,
|
|
docLen - 1,
|
|
trimmed,
|
|
TextSelection.collapsed(offset: trimmed.length),
|
|
);
|
|
|
|
if (context.mounted) {
|
|
showSuccessSnackBar(context, 'Release notes improved with Gemini');
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, 'Gemini failed: $e');
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isImprovingNotes = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
if (_apkBytes == null || _apkName == null) {
|
|
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<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: 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<double>(
|
|
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<double>(
|
|
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,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|