feat(app): P0 batch 3 — offline detection with engine pause/resume + UI banner

P0-4 离线感知:
  - connectivity_plus → NetworkAvailability(online/offline/unknown)
  - engine_scope 三重绑定:有账号+有网 → start;任一失去 → stop(在途不中断)
  - 网络恢复 → start + onNetworkRestored(waitingNetwork 即时重试)
  - HomePage 顶部离线黄条(wifi_off + i18n 双语)

P0-5 更正:EXIF 方向已由服务端 sharp .rotate() 自动转正——v2 分析误报

397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 11:23:51 +08:00
parent 638dd9b45d
commit 33f347cdea
11 changed files with 643 additions and 6 deletions
+43
View File
@@ -0,0 +1,43 @@
/// 网络连接状态 Provider(P0-4:离线感知)。
///
/// connectivity_plus 监听网络变化 → 全局 Stream → UI/引擎消费:
/// - **UI**:顶部黄条「当前无网络连接」(HomePage 或全局 Scaffold 叠加)
/// - **上传引擎**:离线 → 暂停再出队;恢复 → 自动重试
///
/// 三档:online / offline / unknown(初始/权限不足)。
/// offline 只暂停再出队,**不中断在途传输**——避免假离线(VPN 等)打断上传。
library;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 网络可用性(三档)。
enum NetworkAvailability { online, offline, unknown }
/// 网络状态 Provider(StreamProvider——connectivity_plus Stream 直通)。
final networkStatusProvider = StreamProvider<NetworkAvailability>((ref) async* {
final connectivity = Connectivity();
try {
// 初始值(checkConnectivity 是 Future<List<ConnectivityResult>>)。
final initial = await connectivity.checkConnectivity();
yield _map(initial);
// 后续变化(onConnectivityChanged 是 Stream<List<ConnectivityResult>>)。
yield* connectivity.onConnectivityChanged.map(_map);
} on Object {
// 权限不足/平台不支持——报 unknown(不阻塞 App)。
yield NetworkAvailability.unknown;
}
});
NetworkAvailability _map(List<ConnectivityResult> results) {
if (results.contains(ConnectivityResult.none)) {
return NetworkAvailability.offline;
}
if (results.isEmpty) return NetworkAvailability.unknown;
return NetworkAvailability.online;
}
/// 便捷读取:当前是否有网(unknown 视为有网——假阴性代价低于假阳性)。
bool isNetworkOnline(NetworkAvailability? status) {
return status != NetworkAvailability.offline;
}
@@ -15,6 +15,7 @@ library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api_client.dart';
import '../../core/network_status.dart';
import 'providers.dart';
import 'upload_engine.dart';
@@ -49,14 +50,29 @@ final sessionAccountProvider =
/// 引擎生命周期绑定:账号在场 → start;离场 → stop(任务保留库内)。
///
/// P0-4 离线感知:**离线 → stop(暂停再出队,在途不受影响)**;
/// **恢复 → start + onNetworkRestored(立即重试 waitingNetwork)**。
/// 引擎与账号/网络三重绑定:有账号且有网才出队,任一失去即停。
///
/// 返回当前 scope 账号(null=引擎停止);仅被 App 壳 watch 时生效
/// (Provider 惰性求值)。
final uploadEngineScopeProvider = Provider<String?>((ref) {
final account = ref.watch(sessionAccountProvider).value;
final engine = ref.watch(uploadEngineProvider);
if (account != null) {
// P0-4:网络状态(offline 时不出队;unknown 视为有网)。
final network = ref.watch(networkStatusProvider).value;
final online = isNetworkOnline(network);
if (account != null && online) {
// 恢复在线且已有账号 → start(幂等:已 start 则只刷新 timer)。
engine.start(account);
// 网络从 offline → online 的跃变触发即时重试。
if (network == NetworkAvailability.online) {
engine.onNetworkRestored();
}
} else {
// 离线(或无账号)→ stop(不中断在途;恢复时上面的 start 恢复出队)。
engine.stop();
}
ref.onDispose(engine.stop);
+2 -1
View File
@@ -428,5 +428,6 @@
"posterSaved": "Poster saved to gallery",
"posterSaveFailed": "Failed to save poster",
"close": "Close",
"albumUploadDisabled": "Upload is disabled for this album"
"albumUploadDisabled": "Upload is disabled for this album",
"networkOfflineBanner": "No network. Photos will upload automatically when reconnected."
}
@@ -2023,6 +2023,12 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'该相册已关闭上传,无法添加照片'**
String get albumUploadDisabled;
/// No description provided for @networkOfflineBanner.
///
/// In zh, this message translates to:
/// **'当前无网络连接,照片将在恢复后自动上传'**
String get networkOfflineBanner;
}
class _AppLocalizationsDelegate
@@ -1034,4 +1034,8 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get albumUploadDisabled => 'Upload is disabled for this album';
@override
String get networkOfflineBanner =>
'No network. Photos will upload automatically when reconnected.';
}
@@ -1001,4 +1001,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get albumUploadDisabled => '该相册已关闭上传,无法添加照片';
@override
String get networkOfflineBanner => '当前无网络连接,照片将在恢复后自动上传';
}
+2 -1
View File
@@ -428,5 +428,6 @@
"posterSaved": "海报已保存到相册",
"posterSaveFailed": "海报保存失败",
"close": "关闭",
"albumUploadDisabled": "该相册已关闭上传,无法添加照片"
"albumUploadDisabled": "该相册已关闭上传,无法添加照片",
"networkOfflineBanner": "当前无网络连接,照片将在恢复后自动上传"
}
+42 -3
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/locale_controller.dart';
import '../core/api_client.dart';
import '../core/network_status.dart';
import '../features/albums/providers.dart';
import '../features/uploads/engine_scope.dart';
import '../features/uploads/queue_controller.dart';
@@ -38,10 +39,48 @@ class _HomePageState extends ConsumerState<HomePage> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final badgeCount = ref.watch(uploadBadgeCountProvider);
// P0-4:离线黄条(网络恢复自动消失)。
final network = ref.watch(networkStatusProvider).value;
final offline = network == NetworkAvailability.offline;
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: const [AlbumsTab(), QueueTab(), SettingsTab()],
body: Column(
children: [
if (offline)
Material(
color: Theme.of(context).colorScheme.tertiaryContainer,
child: SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
Icons.wifi_off_rounded,
size: 16,
color: Theme.of(context).colorScheme.onTertiaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.networkOfflineBanner,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.onTertiaryContainer,
),
),
),
],
),
),
),
),
Expanded(
child: IndexedStack(
index: _selectedIndex,
children: const [AlbumsTab(), QueueTab(), SettingsTab()],
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
+48
View File
@@ -193,6 +193,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.3.1"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
convert:
dependency: transitive
description:
@@ -225,6 +241,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.15"
dio:
dependency: "direct main"
description:
@@ -716,6 +740,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.19.2"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.5.0"
objective_c:
dependency: transitive
description:
@@ -788,6 +820,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -1241,6 +1281,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
yaml:
dependency: transitive
description:
+1
View File
@@ -39,6 +39,7 @@ dependencies:
share_plus: ^13.3.0
gal: ^2.3.3
fluwx: ^6.0.2
connectivity_plus: ^7.3.1
flutter_launcher_icons: