feat: fix debug updater
This commit is contained in:
+106
-17
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../utils/app_update_util.dart';
|
||||
import '../viewmodels/dev_menu_view_model.dart';
|
||||
|
||||
class DevMenuView extends StatefulWidget {
|
||||
@@ -128,9 +129,9 @@ class _DevMenuViewState extends State<DevMenuView> {
|
||||
children: [
|
||||
_Tile(
|
||||
icon: Icons.update_rounded,
|
||||
title: 'Mock update available',
|
||||
subtitle: 'Simulate OTA update dialog',
|
||||
onTap: () => _showMockUpdateDialog(),
|
||||
title: 'Simulate update download',
|
||||
subtitle: 'Open the download progress screen',
|
||||
onTap: () => _showSimulateUpdateDialog(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -209,28 +210,66 @@ class _DevMenuViewState extends State<DevMenuView> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showMockUpdateDialog() {
|
||||
void _showSimulateUpdateDialog() {
|
||||
final fakeUpdate = UpdateInfo(
|
||||
update: true,
|
||||
version: '99.0.0',
|
||||
notes: 'Bug fixes and performance improvements.',
|
||||
mandatory: false,
|
||||
sha256: 'abc123',
|
||||
download: 'https://example.com/fake-update.apk',
|
||||
);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Update available'),
|
||||
content: const Column(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Simulate update'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Version 99.0.0 is available.'),
|
||||
SizedBox(height: 12),
|
||||
Text('Bug fixes and performance improvements.'),
|
||||
const Text('Choose a simulation mode:'),
|
||||
const SizedBox(height: 16),
|
||||
_SimOption(
|
||||
icon: Icons.download_rounded,
|
||||
label: 'Successful download',
|
||||
description: 'Progress 0→100%, then install, then done',
|
||||
onTap: () {
|
||||
Navigator.pop(dialogContext);
|
||||
context.push(
|
||||
'/update-progress?simulate=true',
|
||||
extra: fakeUpdate,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SimOption(
|
||||
icon: Icons.error_outline_rounded,
|
||||
label: 'Download error',
|
||||
description: 'Fails at 50% with a network timeout',
|
||||
onTap: () {
|
||||
Navigator.pop(dialogContext);
|
||||
context.push(
|
||||
'/update-progress?simulate=true&simulateError=true',
|
||||
extra: fakeUpdate,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SimOption(
|
||||
icon: Icons.wifi_off_rounded,
|
||||
label: 'Real download (will fail)',
|
||||
description: 'Attempts real OTA with fake URL',
|
||||
onTap: () {
|
||||
Navigator.pop(dialogContext);
|
||||
context.push('/update-progress', extra: fakeUpdate);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Update'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -238,6 +277,56 @@ class _DevMenuViewState extends State<DevMenuView> {
|
||||
}
|
||||
}
|
||||
|
||||
class _SimOption extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String description;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SimOption({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.description,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.titleSmall),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@@ -344,7 +344,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: !update.mandatory,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text("Update available"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -358,30 +358,13 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
actions: [
|
||||
if (!update.mandatory)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text("Later"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
|
||||
try {
|
||||
await context.read<SettingsViewModel>().installUpdate(
|
||||
update,
|
||||
onProgress: (progress) {
|
||||
debugPrint(
|
||||
"Download ${(progress * 100).toStringAsFixed(0)}%",
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text("Update failed: $e")));
|
||||
}
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
context.push('/update-progress', extra: update);
|
||||
},
|
||||
child: const Text("Update"),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../utils/app_update_util.dart';
|
||||
import '../viewmodels/update_progress_view_model.dart';
|
||||
|
||||
class UpdateProgressView extends StatefulWidget {
|
||||
final UpdateInfo update;
|
||||
final bool simulate;
|
||||
final bool simulateError;
|
||||
|
||||
const UpdateProgressView({
|
||||
super.key,
|
||||
required this.update,
|
||||
this.simulate = false,
|
||||
this.simulateError = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UpdateProgressView> createState() => _UpdateProgressViewState();
|
||||
}
|
||||
|
||||
class _UpdateProgressViewState extends State<UpdateProgressView> {
|
||||
late final UpdateProgressViewModel _vm;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_vm = UpdateProgressViewModel(
|
||||
update: widget.update,
|
||||
simulate: widget.simulate,
|
||||
simulateError: widget.simulateError,
|
||||
);
|
||||
_vm.addListener(_onVmChange);
|
||||
_vm.start();
|
||||
}
|
||||
|
||||
void _onVmChange() {
|
||||
if (!mounted) return;
|
||||
|
||||
if (_vm.phase == OtaPhase.done) {
|
||||
_showRestartDialog();
|
||||
} else if (_vm.phase == OtaPhase.error) {
|
||||
_showErrorSnackBar();
|
||||
}
|
||||
}
|
||||
|
||||
void _showRestartDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Update ready'),
|
||||
content: const Text(
|
||||
'The update has been downloaded and installed. '
|
||||
'Restart the app now to apply the changes.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
this.context.go('/bar');
|
||||
},
|
||||
child: const Text('Later'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Restart app'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showErrorSnackBar() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_vm.errorMessage ?? 'Update failed'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
action: SnackBarAction(
|
||||
label: 'Retry',
|
||||
onPressed: () {
|
||||
_vm.cancel();
|
||||
_vm.start();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_vm.removeListener(_onVmChange);
|
||||
_vm.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _showCancelDialog(),
|
||||
),
|
||||
title: const Text('Updating'),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: _vm,
|
||||
builder: (context, child) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 40),
|
||||
_buildProgress(_vm),
|
||||
const SizedBox(height: 24),
|
||||
_buildStatus(_vm),
|
||||
const Spacer(),
|
||||
_buildVersionInfo(_vm),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.system_update_alt_rounded,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'KoolTab',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Version ${widget.update.version}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgress(UpdateProgressViewModel vm) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox.expand(
|
||||
child: CircularProgressIndicator(
|
||||
value: vm.phase == OtaPhase.installing ? null : vm.progress,
|
||||
strokeWidth: 10,
|
||||
backgroundColor: scheme.primary.withValues(alpha: 0.12),
|
||||
color: vm.phase == OtaPhase.error
|
||||
? scheme.error
|
||||
: scheme.primary,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (vm.phase == OtaPhase.error)
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: 48,
|
||||
color: scheme.error,
|
||||
)
|
||||
else if (vm.phase == OtaPhase.installing)
|
||||
Icon(
|
||||
Icons.settings_rounded,
|
||||
size: 48,
|
||||
color: scheme.primary,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'${(vm.progress * 100).toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.headlineLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
vm.phase == OtaPhase.installing ? 'Installing' : '',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (vm.phase == OtaPhase.downloading)
|
||||
LinearProgressIndicator(
|
||||
value: vm.progress,
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatus(UpdateProgressViewModel vm) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
final color = switch (vm.phase) {
|
||||
OtaPhase.error => scheme.error,
|
||||
OtaPhase.done => Colors.green,
|
||||
_ => scheme.onSurface,
|
||||
};
|
||||
|
||||
return Text(
|
||||
vm.statusText,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: color),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVersionInfo(UpdateProgressViewModel vm) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Do not close the app during the update',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showCancelDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Cancel update?'),
|
||||
content: const Text(
|
||||
'The update is in progress. If you leave now, '
|
||||
'the app may become unstable.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Continue update'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_vm.cancel();
|
||||
this.context.go('/bar');
|
||||
},
|
||||
child: const Text('Cancel update'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user