Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -0,0 +1,86 @@
import 'package:dio/dio.dart';
import 'package:miaoji_zhang/shared/update/update_config.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
enum UpdateFailureKind { unavailable, rateLimited, network, invalidResponse }
class UpdateCheckException implements Exception {
final UpdateFailureKind kind;
final String message;
const UpdateCheckException(this.kind, this.message);
@override
String toString() => message;
}
class UpdateApi {
UpdateApi({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
baseUrl: UpdateConfig.baseUrl,
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 8),
),
);
final Dio _dio;
Future<UpdateCheckResponse> check({
required String platform,
required String channel,
required int currentBuild,
}) async {
try {
final response = await _dio.get<Object?>(
'/api/client/v1/update',
queryParameters: {
'appKey': UpdateConfig.appKey,
'platform': platform,
'channel': channel,
'currentBuild': currentBuild,
},
);
final data = response.data;
if (data is! Map) {
throw const UpdateCheckException(
UpdateFailureKind.invalidResponse,
'更新服务返回格式异常',
);
}
return UpdateCheckResponse.fromJson(Map<String, dynamic>.from(data));
} on DioException catch (error) {
switch (error.response?.statusCode) {
case 404:
throw const UpdateCheckException(
UpdateFailureKind.unavailable,
'当前版本尚未配置更新渠道',
);
case 429:
throw const UpdateCheckException(
UpdateFailureKind.rateLimited,
'检查更新过于频繁,请稍后再试',
);
}
throw const UpdateCheckException(
UpdateFailureKind.network,
'暂时无法连接更新服务,请检查网络后重试',
);
} on UpdateCheckException {
rethrow;
} on FormatException catch (error) {
throw UpdateCheckException(
UpdateFailureKind.invalidResponse,
error.message.toString(),
);
} catch (_) {
throw const UpdateCheckException(
UpdateFailureKind.invalidResponse,
'更新服务返回格式异常',
);
}
}
}
@@ -0,0 +1,27 @@
import 'dart:io';
class UpdateConfig {
UpdateConfig._();
static const baseUrl = String.fromEnvironment(
'UPDATE_BASE_URL',
defaultValue: 'https://version.nxsir.cn',
);
static const appKey = String.fromEnvironment(
'UPDATE_APP_KEY',
defaultValue: 'MvSyHJ7d8mlLbylIub04epC7g5AsCSqB',
);
static const internalBuild = bool.fromEnvironment('INTERNAL_BUILD');
static String? get platform {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
static String get channel => internalBuild ? 'beta' : 'stable';
static bool get supportsUpdates => platform != null;
static bool get canInstallInApp => Platform.isAndroid && internalBuild;
}
@@ -0,0 +1,168 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/update/update_api.dart';
import 'package:miaoji_zhang/shared/update/update_config.dart';
import 'package:miaoji_zhang/shared/update/update_downloader.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
import 'package:miaoji_zhang/shared/update/update_prompt.dart';
import 'package:miaoji_zhang/shared/version.dart';
class UpdateCoordinator extends ChangeNotifier {
UpdateCoordinator({UpdateApi? api}) : _api = api ?? UpdateApi();
static final instance = UpdateCoordinator();
final UpdateApi _api;
SharedPreferences? _preferences;
GlobalKey<NavigatorState>? _navigatorKey;
Future<UpdateCheckResponse>? _inFlight;
bool _startupScheduled = false;
bool _showingPrompt = false;
bool _checking = false;
AppRelease? _forcedRelease;
bool get checking => _checking;
Future<void> initialize() async {
_preferences ??= await SharedPreferences.getInstance();
await UpdateDownloader().cleanupStale();
}
void bindNavigator(GlobalKey<NavigatorState> navigatorKey) {
_navigatorKey = navigatorKey;
}
void scheduleStartupCheck() {
if (_startupScheduled || !UpdateConfig.supportsUpdates) return;
_startupScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Future<void>.delayed(
const Duration(milliseconds: 350),
_checkAutomatically,
);
});
}
Future<void> checkManually(BuildContext context) async {
if (!UpdateConfig.supportsUpdates) {
_message(context, '当前平台暂不支持应用内更新检查');
return;
}
try {
final response = await _check();
final release = _availableRelease(response);
if (release == null) {
if (context.mounted) _message(context, '已是最新版');
return;
}
if (response.forceUpdate) _forcedRelease = release;
if (context.mounted) {
await _present(context, release, response.forceUpdate);
}
} on UpdateCheckException catch (error) {
if (context.mounted) _message(context, error.message);
}
}
Future<void> _checkAutomatically() async {
try {
final response = await _check();
final release = _availableRelease(response);
if (release == null) return;
final ignoredId = await _ignoredReleaseId();
if (!UpdatePolicy.shouldPresent(
release: release,
forced: response.forceUpdate,
manual: false,
ignoredReleaseId: ignoredId,
)) {
return;
}
if (response.forceUpdate) _forcedRelease = release;
final context = _navigatorKey?.currentContext;
if (context != null && context.mounted) {
await _present(context, release, response.forceUpdate);
}
} catch (_) {
// Automatic checks never block startup when the update service is unavailable.
}
}
@visibleForTesting
Future<UpdateCheckResponse> debugCheck({required String platform}) =>
_check(platformOverride: platform);
Future<UpdateCheckResponse> _check({String? platformOverride}) {
final existing = _inFlight;
if (existing != null) return existing;
_checking = true;
notifyListeners();
final future = _api
.check(
platform: platformOverride ?? UpdateConfig.platform!,
channel: UpdateConfig.channel,
currentBuild: AppVersion.buildNumber,
)
.whenComplete(() {
_inFlight = null;
_checking = false;
notifyListeners();
});
_inFlight = future;
return future;
}
AppRelease? _availableRelease(UpdateCheckResponse response) =>
UpdatePolicy.availableRelease(
response,
currentBuild: AppVersion.buildNumber,
);
Future<String?> _ignoredReleaseId() async {
final preferences = _preferences ??= await SharedPreferences.getInstance();
return preferences.getString(_ignoredKey);
}
Future<void> _ignore(AppRelease release) async {
final preferences = _preferences ??= await SharedPreferences.getInstance();
await preferences.setString(_ignoredKey, release.id);
}
String get _ignoredKey =>
'ignored_update_${UpdateConfig.platform}_${UpdateConfig.channel}';
Future<void> _present(
BuildContext context,
AppRelease release,
bool forced,
) async {
if (_showingPrompt) return;
_showingPrompt = true;
try {
await showUpdatePrompt(
context,
release: release,
forced: forced,
onIgnore: () => _ignore(release),
);
} finally {
_showingPrompt = false;
if (forced && _forcedRelease != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final retryContext = _navigatorKey?.currentContext;
if (retryContext != null && retryContext.mounted) {
unawaited(_present(retryContext, _forcedRelease!, true));
}
});
}
}
}
void _message(BuildContext context, String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
@@ -0,0 +1,190 @@
import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:miaoji_zhang/shared/update/update_installer.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
class UpdateDownloadProgress {
final int received;
final int total;
final double bytesPerSecond;
const UpdateDownloadProgress({
required this.received,
required this.total,
required this.bytesPerSecond,
});
double? get fraction => total > 0 ? (received / total).clamp(0, 1) : null;
}
class UpdateDownloadException implements Exception {
final String message;
const UpdateDownloadException(this.message);
@override
String toString() => message;
}
class UpdateDownloader {
UpdateDownloader({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(minutes: 10),
followRedirects: true,
maxRedirects: 5,
),
);
final Dio _dio;
CancelToken? _cancelToken;
bool get isDownloading => _cancelToken != null;
void cancel() => _cancelToken?.cancel('用户取消下载');
Future<void> cleanupStale() async {
if (!Platform.isAndroid) return;
final directory = await _updateDirectory();
if (!await directory.exists()) return;
final cutoff = DateTime.now().subtract(const Duration(hours: 24));
await for (final entity in directory.list()) {
if (entity is! File) continue;
final stat = await entity.stat();
if (entity.path.endsWith('.part') || stat.modified.isBefore(cutoff)) {
await _delete(entity);
}
}
}
Future<String> downloadAndVerify(
AppRelease release, {
required void Function(UpdateDownloadProgress progress) onProgress,
}) async {
if (_cancelToken != null) {
throw const UpdateDownloadException('更新包正在下载中');
}
final uri = Uri.tryParse(release.downloadUrl);
if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) {
throw const UpdateDownloadException('更新包地址无效,必须使用 HTTPS');
}
final expectedSha = release.sha256?.trim().toLowerCase();
if (expectedSha == null ||
!RegExp(r'^[0-9a-f]{64}$').hasMatch(expectedSha)) {
throw const UpdateDownloadException('发布记录缺少有效的 SHA-256,已禁止安装');
}
final directory = await _updateDirectory();
await directory.create(recursive: true);
final partial = File(
'${directory.path}${Platform.pathSeparator}jizhi-${release.buildNumber}.apk.part',
);
final target = File(
'${directory.path}${Platform.pathSeparator}jizhi-${release.buildNumber}.apk',
);
await _delete(partial);
await _delete(target);
final token = CancelToken();
_cancelToken = token;
final stopwatch = Stopwatch()..start();
var lastBytes = 0;
var lastElapsed = Duration.zero;
var keepTarget = false;
try {
await _dio.download(
release.downloadUrl,
partial.path,
cancelToken: token,
deleteOnError: true,
onReceiveProgress: (received, total) {
final elapsed = stopwatch.elapsed;
final deltaMicros = (elapsed - lastElapsed).inMicroseconds;
final speed = deltaMicros <= 0
? 0.0
: (received - lastBytes) *
Duration.microsecondsPerSecond /
deltaMicros;
if (elapsed - lastElapsed >= const Duration(milliseconds: 250) ||
received == total) {
lastBytes = received;
lastElapsed = elapsed;
onProgress(
UpdateDownloadProgress(
received: received,
total: total > 0 ? total : release.fileSize ?? 0,
bytesPerSecond: speed,
),
);
}
},
);
final actualSize = await partial.length();
if (release.fileSize case final expectedSize?
when expectedSize > 0 && actualSize != expectedSize) {
throw UpdateDownloadException(
'更新包大小不一致(预期 $expectedSize 字节,实际 $actualSize 字节)',
);
}
final actualSha = (await sha256.bind(partial.openRead()).first)
.toString();
if (actualSha.toLowerCase() != expectedSha) {
throw const UpdateDownloadException('更新包完整性校验失败,文件已删除');
}
await partial.rename(target.path);
final inspection = await UpdateInstaller.inspect(
path: target.path,
expectedBuild: release.buildNumber,
);
if (!inspection.valid) {
throw UpdateDownloadException(inspection.message ?? '更新包身份校验失败,文件已删除');
}
keepTarget = true;
return target.path;
} on DioException catch (error) {
if (CancelToken.isCancel(error)) {
throw const UpdateDownloadException('下载已取消');
}
throw const UpdateDownloadException('更新包下载失败,请检查网络后重试');
} on UpdateDownloadException {
rethrow;
} catch (_) {
throw const UpdateDownloadException('更新包处理失败,请重新下载');
} finally {
_cancelToken = null;
stopwatch.stop();
if (!keepTarget) {
await _delete(partial);
await _delete(target);
}
}
}
Future<void> deleteFile(String path) => _delete(File(path));
Future<void> scheduleCleanup(String path) async {
unawaited(
Future<void>.delayed(const Duration(minutes: 10), () async {
await _delete(File(path));
}),
);
}
Future<Directory> _updateDirectory() async {
final temporary = await getTemporaryDirectory();
return Directory('${temporary.path}${Platform.pathSeparator}updates');
}
Future<void> _delete(File file) async {
try {
if (await file.exists()) await file.delete();
} catch (_) {}
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/services.dart';
enum ApkInstallStatus { launched, permissionRequested, unsupported }
class ApkInspection {
final bool valid;
final String? message;
const ApkInspection({required this.valid, this.message});
}
class UpdateInstaller {
UpdateInstaller._();
static const _channel = MethodChannel('com.nx.miaoji/update');
static Future<ApkInspection> inspect({
required String path,
required int expectedBuild,
}) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'inspectApk',
{'path': path, 'expectedBuild': expectedBuild},
);
return ApkInspection(
valid: result?['valid'] == true,
message: result?['message']?.toString(),
);
}
static Future<ApkInstallStatus> install({
required String path,
required int expectedBuild,
}) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'installApk',
{'path': path, 'expectedBuild': expectedBuild},
);
return switch (result?['status']) {
'launched' => ApkInstallStatus.launched,
'permission_requested' => ApkInstallStatus.permissionRequested,
_ => ApkInstallStatus.unsupported,
};
}
}
@@ -0,0 +1,99 @@
class AppRelease {
final String id;
final String versionName;
final int buildNumber;
final String downloadUrl;
final String releaseNotes;
final String? sha256;
final int? fileSize;
final DateTime? publishedAt;
const AppRelease({
required this.id,
required this.versionName,
required this.buildNumber,
required this.downloadUrl,
required this.releaseNotes,
required this.sha256,
required this.fileSize,
required this.publishedAt,
});
factory AppRelease.fromJson(Map<String, dynamic> json) {
final id = json['id']?.toString().trim() ?? '';
final versionName = json['versionName']?.toString().trim() ?? '';
final buildNumber = (json['buildNumber'] as num?)?.toInt() ?? -1;
final downloadUrl = json['downloadUrl']?.toString().trim() ?? '';
if (id.isEmpty ||
versionName.isEmpty ||
buildNumber < 0 ||
downloadUrl.isEmpty) {
throw const FormatException('更新服务返回了不完整的版本信息');
}
return AppRelease(
id: id,
versionName: versionName,
buildNumber: buildNumber,
downloadUrl: downloadUrl,
releaseNotes: json['releaseNotes']?.toString().trim() ?? '',
sha256: json['sha256']?.toString().trim().nullIfEmpty,
fileSize: (json['fileSize'] as num?)?.toInt(),
publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? ''),
);
}
}
class UpdateCheckResponse {
final bool hasUpdate;
final bool forceUpdate;
final AppRelease? release;
const UpdateCheckResponse({
required this.hasUpdate,
required this.forceUpdate,
required this.release,
});
factory UpdateCheckResponse.fromJson(Map<String, dynamic> json) {
final releaseJson = json['release'];
final release = releaseJson is Map
? AppRelease.fromJson(Map<String, dynamic>.from(releaseJson))
: null;
final hasUpdate = json['hasUpdate'] == true;
if (hasUpdate && release == null) {
throw const FormatException('更新服务未返回目标版本');
}
return UpdateCheckResponse(
hasUpdate: hasUpdate,
forceUpdate: json['forceUpdate'] == true,
release: release,
);
}
}
extension on String {
String? get nullIfEmpty => isEmpty ? null : this;
}
class UpdatePolicy {
UpdatePolicy._();
static AppRelease? availableRelease(
UpdateCheckResponse response, {
required int currentBuild,
}) {
final release = response.release;
if (!response.hasUpdate || release == null) return null;
return release.buildNumber > currentBuild ? release : null;
}
static bool shouldPresent({
required AppRelease release,
required bool forced,
required bool manual,
required String? ignoredReleaseId,
}) {
if (forced || manual) return true;
return ignoredReleaseId != release.id;
}
}
@@ -0,0 +1,376 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/update/update_config.dart';
import 'package:miaoji_zhang/shared/update/update_downloader.dart';
import 'package:miaoji_zhang/shared/update/update_installer.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
import 'package:miaoji_zhang/shared/version.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
Future<void> showUpdatePrompt(
BuildContext context, {
required AppRelease release,
required bool forced,
required Future<void> Function() onIgnore,
}) {
return showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
backgroundColor: Colors.transparent,
builder: (_) =>
UpdatePromptSheet(release: release, forced: forced, onIgnore: onIgnore),
);
}
class UpdatePromptSheet extends StatefulWidget {
final AppRelease release;
final bool forced;
final Future<void> Function() onIgnore;
const UpdatePromptSheet({
super.key,
required this.release,
required this.forced,
required this.onIgnore,
});
@override
State<UpdatePromptSheet> createState() => _UpdatePromptSheetState();
}
class _UpdatePromptSheetState extends State<UpdatePromptSheet> {
final _downloader = UpdateDownloader();
UpdateDownloadProgress? _progress;
bool _working = false;
String? _status;
String? _error;
String? _downloadedPath;
@override
void dispose() {
_downloader.cancel();
super.dispose();
}
Future<void> _ignore() async {
if (_working) return;
await widget.onIgnore();
if (mounted) Navigator.pop(context);
}
Future<void> _update() async {
if (_working) return;
final uri = Uri.tryParse(widget.release.downloadUrl);
if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) {
setState(() => _error = '更新地址无效,必须使用 HTTPS');
return;
}
if (!UpdateConfig.canInstallInApp) {
setState(() {
_working = true;
_error = null;
_status = '正在打开更新页面…';
});
try {
final opened = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!opened) throw StateError('无法打开更新页面');
if (!widget.forced && mounted) Navigator.pop(context);
if (mounted && widget.forced) {
setState(() => _status = '更新页面已打开,完成更新后重新打开记之');
}
} catch (_) {
if (mounted) setState(() => _error = '无法打开更新页面,请稍后重试');
} finally {
if (mounted) setState(() => _working = false);
}
return;
}
setState(() {
_working = true;
_error = null;
_status = '正在下载更新包…';
_progress = null;
});
String? path;
try {
final cachedPath = _downloadedPath;
if (cachedPath != null && await File(cachedPath).exists()) {
path = cachedPath;
} else {
_downloadedPath = null;
path = await _downloader.downloadAndVerify(
widget.release,
onProgress: (progress) {
if (!mounted) return;
setState(() {
_progress = progress;
_status = '正在下载更新包…';
});
},
);
_downloadedPath = path;
}
if (!mounted) return;
setState(() {
_status = '校验完成,正在打开安装界面…';
_progress = null;
});
final status = await UpdateInstaller.install(
path: path,
expectedBuild: widget.release.buildNumber,
);
if (!mounted) return;
setState(() {
_status = switch (status) {
ApkInstallStatus.launched => '安装界面已打开,完成后重新启动记之',
ApkInstallStatus.permissionRequested => '请允许安装未知应用,返回后将继续安装',
ApkInstallStatus.unsupported => '当前设备无法调起安装,请重新下载',
};
if (status == ApkInstallStatus.unsupported) {
_error = _status;
}
});
if (status == ApkInstallStatus.launched) {
await _downloader.scheduleCleanup(path);
} else if (status == ApkInstallStatus.unsupported) {
await _downloader.deleteFile(path);
_downloadedPath = null;
}
} on UpdateDownloadException catch (error) {
if (mounted) setState(() => _error = error.message);
} catch (_) {
if (mounted) setState(() => _error = '更新失败,请重新尝试');
} finally {
if (mounted) setState(() => _working = false);
}
}
void _cancelDownload() {
_downloader.cancel();
setState(() => _status = '正在取消下载…');
}
@override
Widget build(BuildContext context) {
final release = widget.release;
return PopScope(
canPop: false,
child: SafeArea(
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.84,
),
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
JzSheetHeader(
title: widget.forced ? '必须更新后继续使用' : '发现新版本',
subtitle:
'v${release.versionName} (${release.buildNumber}) · 当前 ${AppVersion.display}',
),
if (widget.forced)
Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.orange.withValues(alpha: 0.35),
),
),
child: Text(
'此版本已停止支持,更新完成前暂时不能进入 App。',
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
height: 1.45,
),
),
),
const SizedBox(height: 14),
Flexible(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: context.jz.background,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line),
),
child: SingleChildScrollView(
child: MarkdownBody(
data: release.releaseNotes.isEmpty
? '本次更新包含体验优化与问题修复。'
: release.releaseNotes,
selectable: true,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
fontSize: 13,
color: context.jz.text2,
height: 1.65,
),
h1: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: context.jz.text,
),
h2: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: context.jz.text,
),
listBullet: TextStyle(
fontSize: 13,
color: AppTheme.primaryDeep,
),
code: TextStyle(
fontSize: 12,
color: context.jz.text,
backgroundColor: context.jz.card,
),
blockquoteDecoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(8),
border: const Border(
left: BorderSide(color: AppTheme.ai, width: 3),
),
),
),
),
),
),
),
if (_progress != null || _status != null || _error != null) ...[
const SizedBox(height: 14),
if (_progress != null) _UpdateProgressBar(progress: _progress!),
if (_progress != null) const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
_error ?? _status ?? '',
style: TextStyle(
fontSize: 11.5,
color: _error == null ? context.jz.text2 : AppTheme.red,
),
),
),
],
const SizedBox(height: 18),
if (_working && _downloader.isDownloading && !widget.forced)
SizedBox(
width: double.infinity,
child: JzActionButton(
label: '取消下载',
secondary: true,
onPressed: _cancelDownload,
),
)
else
Row(
children: [
if (!widget.forced) ...[
Expanded(
child: JzActionButton(
label: '忽略此版本',
secondary: true,
onPressed: _working ? null : _ignore,
),
),
const SizedBox(width: 10),
],
Expanded(
child: JzActionButton(
label: UpdateConfig.canInstallInApp
? (_downloadedPath == null ? '下载并安装' : '继续安装')
: '立即更新',
loading: _working,
onPressed: _working ? null : _update,
),
),
],
),
],
),
),
),
);
}
}
class _UpdateProgressBar extends StatelessWidget {
final UpdateDownloadProgress progress;
const _UpdateProgressBar({required this.progress});
@override
Widget build(BuildContext context) {
final fraction = progress.fraction ?? 0;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
height: 7,
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: context.jz.line),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: fraction,
child: const ColoredBox(color: AppTheme.primary),
),
],
),
),
),
const SizedBox(height: 6),
Row(
children: [
Text(
progress.total > 0
? '${(fraction * 100).toStringAsFixed(0)}%'
: _formatBytes(progress.received),
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
const Spacer(),
Text(
'${_formatBytes(progress.bytesPerSecond.round())}/s',
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
],
),
],
);
}
}
String _formatBytes(int bytes) {
if (bytes >= 1024 * 1024) {
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
}
if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '$bytes B';
}