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:
@@ -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);
|
||||
|
||||
@@ -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 => '当前无网络连接,照片将在恢复后自动上传';
|
||||
}
|
||||
|
||||
@@ -428,5 +428,6 @@
|
||||
"posterSaved": "海报已保存到相册",
|
||||
"posterSaveFailed": "海报保存失败",
|
||||
"close": "关闭",
|
||||
"albumUploadDisabled": "该相册已关闭上传,无法添加照片"
|
||||
"albumUploadDisabled": "该相册已关闭上传,无法添加照片",
|
||||
"networkOfflineBanner": "当前无网络连接,照片将在恢复后自动上传"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user