Files
2026-06-18 15:02:28 +08:00

421 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/config/shorebird_service.dart';
import '../../../common/router/app_router.dart';
import '../../../common/theme/app_text.dart';
import '../../../common/theme/spacing.dart';
class DebugShorebirdPage extends StatefulWidget {
const DebugShorebirdPage({super.key});
@override
State<DebugShorebirdPage> createState() => _DebugShorebirdPageState();
}
class _DebugShorebirdPageState extends State<DebugShorebirdPage> {
final ShorebirdService _shorebird = ShorebirdService.instance;
late final bool _isUpdaterAvailable;
var _currentTrack = UpdateTrack.stable;
var _isCheckingForUpdates = false;
Patch? _currentPatch;
@override
void initState() {
super.initState();
_isUpdaterAvailable = _shorebird.isAvailable;
_loadCurrentPatch();
}
Future<void> _loadCurrentPatch() async {
try {
await _shorebird.initCurrentPatch();
if (!mounted) return;
setState(() => _currentPatch = _shorebird.currentPatch);
} catch (error) {
debugPrint('Error reading current patch: $error');
}
}
Future<void> _checkForUpdate() async {
if (_isCheckingForUpdates) return;
try {
setState(() => _isCheckingForUpdates = true);
final status = await _shorebird.checkForUpdate(track: _currentTrack);
if (!mounted) return;
switch (status) {
case UpdateStatus.upToDate:
_showNoUpdateAvailableBanner();
break;
case UpdateStatus.outdated:
_showUpdateAvailableBanner();
break;
case UpdateStatus.restartRequired:
_showRestartBanner();
break;
case UpdateStatus.unavailable:
_showUnavailableBanner();
break;
}
} catch (error) {
debugPrint('Error checking for update: $error');
_showErrorBanner(error);
} finally {
if (mounted) {
setState(() => _isCheckingForUpdates = false);
}
}
}
void _showDownloadingBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
const MaterialBanner(
content: Text('正在下载更新...'),
actions: [
SizedBox(height: 14, width: 14, child: CircularProgressIndicator()),
],
),
);
}
void _showUpdateAvailableBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: Text('当前 ${_currentTrack.name} track 有可用更新。'),
actions: [
TextButton(
onPressed: () async {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
await _downloadUpdate();
if (!mounted) return;
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Download'),
),
],
),
);
}
void _showNoUpdateAvailableBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: Text('当前 ${_currentTrack.name} track 没有可用更新。'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
void _showRestartBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: const Text('新的 patch 已准备好,请重启应用。'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
void _showUnavailableBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: const Text('当前构建不可用 Shorebird。'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
void _showErrorBanner(Object error) {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: Text('检查更新时发生错误:$error'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
Future<void> _downloadUpdate() async {
_showDownloadingBanner();
try {
await _shorebird.downloadUpdate(track: _currentTrack);
if (!mounted) return;
setState(() => _currentPatch = _shorebird.currentPatch);
_showRestartBanner();
} on UpdateException catch (error) {
_showErrorBanner(error.message);
} catch (error) {
_showErrorBanner(error);
}
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(
'Shorebird',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios_new, color: cs.foreground, size: 18),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.debug);
}
},
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(Spacing.page, 8, Spacing.page, 24),
children: [
if (!_isUpdaterAvailable) ...[
_Card(
child: Text(
'当前构建未接入 Shorebird。请确认应用是通过 `shorebird release` 生成的 release 包,然后再检查更新与 patch 状态。',
style: AppText.body.copyWith(color: cs.mutedForeground),
),
),
const SizedBox(height: Spacing.cardGap),
],
_Section(
title: '当前状态',
child: _Card(
child: Column(
children: [
_InfoTile(
title: '当前 patch 版本',
value: _currentPatch != null
? '${_currentPatch!.number}'
: '未安装 patch',
),
Divider(height: 1, thickness: 1, color: cs.border),
_InfoTile(
title: '更新器可用',
value: _isUpdaterAvailable ? '是' : '否',
),
],
),
),
),
_Section(
title: 'Track 选择',
child: _Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'选择要检查和下载的 track。',
style: AppText.body.copyWith(color: cs.mutedForeground),
),
const SizedBox(height: 12),
SegmentedButton<UpdateTrack>(
segments: const [
ButtonSegment(
label: Text('Stable'),
value: UpdateTrack.stable,
),
ButtonSegment(
label: Text('Beta'),
value: UpdateTrack.beta,
),
ButtonSegment(
label: Text('Staging'),
value: UpdateTrack.staging,
),
],
selected: {_currentTrack},
onSelectionChanged: (tracks) {
setState(() => _currentTrack = tracks.single);
},
),
],
),
),
),
),
_Section(
title: '操作',
child: _Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: FilledButton(
onPressed: _isCheckingForUpdates
? null
: _checkForUpdate,
child: _isCheckingForUpdates
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('检查更新'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: _downloadUpdate,
child: const Text('下载更新'),
),
),
],
),
),
),
),
],
),
);
}
}
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: Spacing.sectionGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 10),
child: Text(
title,
style: AppText.meta.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.mutedForeground,
),
),
),
child,
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusBase),
),
clipBehavior: Clip.antiAlias,
child: child,
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile({required this.title, required this.value});
final String title;
final String value;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppText.meta.copyWith(color: cs.mutedForeground),
),
const SizedBox(height: 4),
Text(
value,
style: AppText.body.copyWith(
color: cs.foreground,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
);
}
}