feat: add app update checks and standalone release publishing panel

This commit is contained in:
Codex
2026-10-24 13:50:42 +08:00
parent 437ed14dbe
commit 75e0b5587d
27 changed files with 1566 additions and 7 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.7.5';
const appBuildNumber = 23;
const appVersion = '0.7.6';
const appBuildNumber = 24;
+61
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -18,6 +20,8 @@ import '../features/school/school_selection_page.dart';
import '../features/school/undergraduate/session.dart';
import '../features/school/undergraduate/exam_query_picker.dart';
import '../domain/undergraduate_exam_query.dart';
import '../services/app_update_service.dart';
import '../features/updates/update_prompt.dart';
class HepanApp extends StatelessWidget {
const HepanApp({
@@ -29,6 +33,8 @@ class HepanApp extends StatelessWidget {
this.undergraduateBuilder,
this.notificationSelection,
this.feedbackSender,
this.updateService,
this.openUpdateDownload = openUpdateInBrowser,
});
final AppController controller;
final Widget Function()? schoolBuilder;
@@ -37,6 +43,8 @@ class HepanApp extends StatelessWidget {
final Widget Function()? undergraduateBuilder;
final ValueNotifier<String?>? notificationSelection;
final FeedbackSender? feedbackSender;
final AppUpdateService? updateService;
final UpdateDownloadOpener openUpdateDownload;
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
@@ -53,6 +61,8 @@ class HepanApp extends StatelessWidget {
undergraduateBuilder: undergraduateBuilder,
notificationSelection: notificationSelection,
feedbackSender: feedbackSender ?? configuredFeedbackSender(),
updateService: updateService,
openUpdateDownload: openUpdateDownload,
),
);
}
@@ -66,6 +76,8 @@ class _Shell extends StatefulWidget {
this.undergraduateBuilder,
this.notificationSelection,
required this.feedbackSender,
this.updateService,
required this.openUpdateDownload,
});
final AppController controller;
final Widget Function()? schoolBuilder;
@@ -74,6 +86,8 @@ class _Shell extends StatefulWidget {
final Widget Function()? undergraduateBuilder;
final ValueNotifier<String?>? notificationSelection;
final FeedbackSender feedbackSender;
final AppUpdateService? updateService;
final UpdateDownloadOpener openUpdateDownload;
@override
State<_Shell> createState() => _ShellState();
}
@@ -81,6 +95,40 @@ class _Shell extends StatefulWidget {
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
int _tab = 0;
bool _openingSchool = false;
bool _checkingUpdate = false;
bool _updatePromptOpen = false;
Timer? _startupUpdate;
Future<void> _checkUpdate({bool silent = false}) async {
if (_checkingUpdate || _updatePromptOpen || widget.updateService == null) {
return;
}
setState(() => _checkingUpdate = true);
try {
final release = await widget.updateService!.check();
if (mounted) setState(() => _checkingUpdate = false);
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
if (release != null) {
_updatePromptOpen = true;
await showUpdatePrompt(
context,
release,
openDownload: widget.openUpdateDownload,
);
} else if (!silent) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('当前已是最新版本')));
}
} on UpdateCheckException catch (error) {
if (!silent && mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(error.message)));
}
} finally {
_updatePromptOpen = false;
if (mounted) setState(() => _checkingUpdate = false);
}
}
// Restore the saved school; legacy graduate imports infer their source.
SupportedSchool? get _knownSchool =>
widget.controller.snapshot.selectedSchool ??
@@ -102,10 +150,19 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
widget.notificationSelection?.addListener(_selectNotification);
WidgetsBinding.instance.addPostFrameCallback((_) => _selectNotification());
if (widget.updateService != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_startupUpdate = Timer(const Duration(seconds: 2), () {
if (mounted) unawaited(_checkUpdate(silent: true));
});
});
}
}
@override
void dispose() {
_startupUpdate?.cancel();
WidgetsBinding.instance.removeObserver(this);
widget.notificationSelection?.removeListener(_selectNotification);
super.dispose();
@@ -400,6 +457,10 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
ProfilePage(
controller: widget.controller,
feedbackSender: widget.feedbackSender,
onCheckUpdates: widget.updateService == null
? null
: () => _checkUpdate(),
checkingUpdates: _checkingUpdate,
onLogin: () => _school(selectSchool: true),
schoolName: _knownSchool?.label,
onUpdateTerm: () => _school(chooseTerm: true),