From c94b9e5e583f4a917ba0442850fa8467899d40ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 21 Sep 2026 14:55:29 +0800 Subject: [PATCH] feat(mobile): UI-01 theme system, shared components and navigation Co-Authored-By: Claude Code --- apps/mobile/lib/app/theme.dart | 256 ++++++++++++++++- .../lib/features/albums/album_format.dart | 66 ++++- .../features/uploads/queue_controller.dart | 118 ++++---- .../lib/features/uploads/queue_l10n.dart | 42 +-- apps/mobile/lib/l10n/app_en.arb | 11 +- apps/mobile/lib/l10n/app_localizations.dart | 38 ++- .../mobile/lib/l10n/app_localizations_en.dart | 20 +- .../mobile/lib/l10n/app_localizations_zh.dart | 20 +- apps/mobile/lib/l10n/app_zh.arb | 11 +- apps/mobile/lib/pages/albums_tab.dart | 270 +++++------------- apps/mobile/lib/pages/home_page.dart | 54 +++- apps/mobile/lib/pages/queue_tab.dart | 156 +++++----- .../mobile/lib/ui/components/empty_state.dart | 76 +++++ .../mobile/lib/ui/components/error_state.dart | 63 ++++ .../lib/ui/components/photo_thumbnail.dart | 67 +++++ .../lib/ui/components/section_header.dart | 37 +++ .../mobile/lib/ui/components/state_badge.dart | 112 ++++++++ apps/mobile/lib/ui/tokens.dart | 83 ++++++ apps/mobile/test/app/theme_test.dart | 174 +++++++++++ apps/mobile/test/helpers/fakes.dart | 192 ++++++------- apps/mobile/test/pages/albums_tab_test.dart | 124 ++++---- apps/mobile/test/pages/home_badge_test.dart | 194 +++++++++++++ apps/mobile/test/ui/empty_state_test.dart | 60 ++++ apps/mobile/test/ui/error_state_test.dart | 61 ++++ apps/mobile/test/ui/photo_thumbnail_test.dart | 196 +++++++++++++ apps/mobile/test/ui/section_header_test.dart | 37 +++ apps/mobile/test/ui/state_badge_test.dart | 84 ++++++ 27 files changed, 2067 insertions(+), 555 deletions(-) create mode 100644 apps/mobile/lib/ui/components/empty_state.dart create mode 100644 apps/mobile/lib/ui/components/error_state.dart create mode 100644 apps/mobile/lib/ui/components/photo_thumbnail.dart create mode 100644 apps/mobile/lib/ui/components/section_header.dart create mode 100644 apps/mobile/lib/ui/components/state_badge.dart create mode 100644 apps/mobile/lib/ui/tokens.dart create mode 100644 apps/mobile/test/app/theme_test.dart create mode 100644 apps/mobile/test/pages/home_badge_test.dart create mode 100644 apps/mobile/test/ui/empty_state_test.dart create mode 100644 apps/mobile/test/ui/error_state_test.dart create mode 100644 apps/mobile/test/ui/photo_thumbnail_test.dart create mode 100644 apps/mobile/test/ui/section_header_test.dart create mode 100644 apps/mobile/test/ui/state_badge_test.dart diff --git a/apps/mobile/lib/app/theme.dart b/apps/mobile/lib/app/theme.dart index 5dc8abd..6d2fa5a 100644 --- a/apps/mobile/lib/app/theme.dart +++ b/apps/mobile/lib/app/theme.dart @@ -1,17 +1,257 @@ +/// UI-01 主题(视觉规范 §3.1 精确落地)。 +/// +/// 浅色基准:primary #1A73E8 / surface #FFFFFF / 页面背景 #F6F8FB / +/// 主文字 #172033 / 次文字 #5E6878 / 分隔线 #E5EAF1;等待色 #996300 承载 +/// ColorScheme.secondary、错误 #B42318 承载 error。成功色**不进** +/// ColorScheme(Material 无该语义角色),以独立令牌提供(见 ui/tokens.dart, +/// 成功/等待/错误均另有深色提亮变体)。 +/// +/// 深色:语义对应色(深表面 + 浅文字 + 提亮的主色/等待色/错误色);图片 +/// 与内容不做反色处理,沿用跟随系统的深浅色切换。容器色(secondary/ +/// primaryContainer 等)由种子色派生,与 #1A73E8 同系。 +/// +/// 字阶(fontSize 走 TextScaler,随系统字体缩放):22 页面标题 / +/// 17 分组标题 / 16-14 正文 / 13-12 辅助。组件:主按钮高 48(区间 +/// 48-52)、输入框与按钮圆角 12、卡片圆角 16、Dialog 圆角 16、 +/// BottomSheet 顶部圆角 16。动效时长常量见 ui/tokens.dart。 +library; + import 'package:flutter/material.dart'; -/// 基础主题(Material 3,种子色)。完整视觉规范随后续任务迭代。 +import '../ui/tokens.dart'; + abstract final class AppTheme { + /// Material 种子色(容器色派生基准)。 static const Color seedColor = Color(0xFF1A73E8); - static ThemeData get light => ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: seedColor), - ); + // ── 浅色基准值(方案 §3.1)────────────────────────────────────────── - static ThemeData get dark => ThemeData( - colorScheme: ColorScheme.fromSeed( + static const Color _primary = Color(0xFF1A73E8); + static const Color _onPrimary = Color(0xFFFFFFFF); + static const Color _surface = Color(0xFFFFFFFF); + static const Color _onSurface = Color(0xFF172033); + static const Color _background = Color(0xFFF6F8FB); + static const Color _textSecondary = Color(0xFF5E6878); + static const Color _divider = Color(0xFFE5EAF1); + static const Color _waiting = Color(0xFF996300); + static const Color _onWaiting = Color(0xFFFFFFFF); + static const Color _error = Color(0xFFB42318); + + // ── 深色对应语义值(同语义、深色表面提亮)────────────────────────── + + static const Color _primaryDark = Color(0xFF8AB4F8); + static const Color _onPrimaryDark = Color(0xFF0B2E6F); + static const Color _surfaceDark = Color(0xFF1F2530); + static const Color _onSurfaceDark = Color(0xFFE6EAF2); + static const Color _backgroundDark = Color(0xFF12161D); + static const Color _textSecondaryDark = Color(0xFFA6B0C0); + static const Color _dividerDark = Color(0xFF333B47); + static const Color _waitingDark = Color(0xFFFCD34D); + static const Color _onWaitingDark = Color(0xFF402E00); + static const Color _errorDark = Color(0xFFF87171); + static const Color _onErrorDark = Color(0xFF5C1A15); + + static ThemeData get light => _build(Brightness.light); + static ThemeData get dark => _build(Brightness.dark); + + static ThemeData _build(Brightness brightness) { + final dark = brightness == Brightness.dark; + final colorScheme = + ColorScheme.fromSeed( seedColor: seedColor, - brightness: Brightness.dark, + brightness: brightness, + ).copyWith( + primary: dark ? _primaryDark : _primary, + onPrimary: dark ? _onPrimaryDark : _onPrimary, + secondary: dark ? _waitingDark : _waiting, + onSecondary: dark ? _onWaitingDark : _onWaiting, + error: dark ? _errorDark : _error, + onError: dark ? _onErrorDark : Colors.white, + surface: dark ? _surfaceDark : _surface, + onSurface: dark ? _onSurfaceDark : _onSurface, + surfaceContainerHighest: dark ? _dividerDark : _divider, + onSurfaceVariant: dark ? _textSecondaryDark : _textSecondary, + ); + + final textTheme = _textTheme(colorScheme); + final buttonBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(kRadiusInput), + ); + + return ThemeData( + brightness: brightness, + colorScheme: colorScheme, + scaffoldBackgroundColor: dark ? _backgroundDark : _background, + textTheme: textTheme, + + // AppBar:干净(零海拔 + 页面背景同色),标题用 titleLarge。 + appBarTheme: AppBarTheme( + backgroundColor: dark ? _backgroundDark : _background, + surfaceTintColor: Colors.transparent, + foregroundColor: colorScheme.onSurface, + titleTextStyle: textTheme.titleLarge, + elevation: 0, + scrolledUnderElevation: 1, + centerTitle: false, + ), + + // 按钮:主/次操作统一高 48(规格 48-52)、圆角 12。 + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size(64, 48), + shape: buttonBorder, ), - ); + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size(64, 48), + shape: buttonBorder, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(48, 48), + shape: buttonBorder, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: const Size(48, 48), + shape: buttonBorder, + ), + ), + + // 输入框:圆角 12,轻分隔线边框,聚焦主色 2px。 + inputDecorationTheme: InputDecorationTheme( + border: _inputBorder(colorScheme, colorScheme.outline), + enabledBorder: _inputBorder( + colorScheme, + dark ? _dividerDark : _divider, + ), + focusedBorder: _inputBorder(colorScheme, colorScheme.primary, width: 2), + errorBorder: _inputBorder(colorScheme, colorScheme.error), + focusedErrorBorder: _inputBorder( + colorScheme, + colorScheme.error, + width: 2, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: kSpace16, + vertical: kSpace12, + ), + ), + + // 卡片:白色表面(深色深表面)、圆角 16、轻阴影。 + cardTheme: CardThemeData( + color: colorScheme.surface, + surfaceTintColor: Colors.transparent, + elevation: 1, + shadowColor: Colors.black.withValues(alpha: dark ? 0.4 : 0.08), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(kRadiusCard), + ), + ), + + // Dialog 圆角 16;BottomSheet 仅顶部圆角 16。 + dialogTheme: DialogThemeData( + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(kRadiusCard), + ), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(kRadiusCard), + ), + ), + ), + + // Snackbar:浮动 + inverseSurface(自动随深浅色),动作色 inversePrimary。 + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: colorScheme.inverseSurface, + contentTextStyle: textTheme.bodyMedium?.copyWith( + color: colorScheme.onInverseSurface, + ), + actionTextColor: colorScheme.inversePrimary, + ), + + // 底部导航:白色表面(深色深表面)+ 派生容器色指示器。 + navigationBarTheme: NavigationBarThemeData( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + indicatorColor: colorScheme.secondaryContainer, + height: 80, + labelTextStyle: WidgetStatePropertyAll( + textTheme.labelMedium!.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + + // 轻分隔线(不用粗边框)。 + dividerTheme: DividerThemeData( + color: dark ? _dividerDark : _divider, + thickness: 1, + space: 1, + ), + ); + } + + static OutlineInputBorder _inputBorder( + ColorScheme scheme, + Color color, { + double width = 1, + }) => OutlineInputBorder( + borderRadius: BorderRadius.circular(kRadiusInput), + borderSide: BorderSide(color: color, width: width), + ); + + /// 字阶(方案 §3.1):22 页面标题 / 17 分组 / 16-14 正文 / 13-12 辅助。 + /// + /// 只声明 fontSize/字重/颜色,字体族与缩放交给默认排版 + TextScaler + /// (fontSize 随系统字体大小缩放)。 + static TextTheme _textTheme(ColorScheme scheme) { + final onSurface = scheme.onSurface; + final onSecondaryText = scheme.onSurfaceVariant; + return TextTheme( + titleLarge: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + color: onSurface, + ), + titleMedium: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: onSurface, + ), + titleSmall: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: onSurface, + ), + bodyLarge: TextStyle(fontSize: 16, color: onSurface), + bodyMedium: TextStyle(fontSize: 14, color: onSurface), + bodySmall: TextStyle(fontSize: 13, color: onSecondaryText), + labelLarge: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: onSurface, + ), + labelMedium: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: onSurface, + ), + labelSmall: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: onSecondaryText, + ), + ); + } } diff --git a/apps/mobile/lib/features/albums/album_format.dart b/apps/mobile/lib/features/albums/album_format.dart index 0fce643..5b0db1e 100644 --- a/apps/mobile/lib/features/albums/album_format.dart +++ b/apps/mobile/lib/features/albums/album_format.dart @@ -1,4 +1,4 @@ -/// 相册时区感知的时间展示(P3 计划 Task 4)。 +/// 相册时区感知的时间展示与多语言展示回退(P3 计划 Task 4;UI-01 §4.1)。 /// /// 契约:startAt/endAt 按 UTC 存储,「展示按 timezone」。Dart 核心库无 /// IANA 时区数据库,引入 `timezone` 包(内嵌 tzdata)做墙钟换算,字符串 @@ -10,6 +10,8 @@ import 'package:intl/intl.dart'; import 'package:timezone/data/latest.dart' as tzdata; import 'package:timezone/timezone.dart' as tz; +import 'models.dart'; + bool _tzInitialized = false; /// 初始化内嵌 tzdata(幂等;首次调用后为空操作)。 @@ -73,14 +75,56 @@ DateTime albumWallClockToUtc(DateTime wallClock, String timeZone) { wallClock.minute, ); } - return tz - .TZDateTime( - location, - wallClock.year, - wallClock.month, - wallClock.day, - wallClock.hour, - wallClock.minute, - ) - .toUtc(); + return tz.TZDateTime( + location, + wallClock.year, + wallClock.month, + wallClock.day, + wallClock.hour, + wallClock.minute, + ).toUtc(); +} + +// ── 多语言展示回退链(UI-01 §4.1)──────────────────────────────────────── +// +// 展示顺序:界面语言(语言前缀匹配,App locale `zh`/`en` vs 契约 +// `zh-CN`/`en`)→ 相册默认语言 → 首个条目 → 相册 ID 兜底。 + +/// 展示回退链:界面语言条目优先,其余按「默认语言在前」排列。 +List albumDisplayTranslations( + AlbumSummary album, + String localeName, +) { + final lang = localeName.split('-').first.toLowerCase(); + AlbumTranslation? langMatch; + for (final t in album.translations) { + final l = t.locale.toLowerCase(); + if (l == localeName.toLowerCase() || l.split('-').first == lang) { + langMatch = t; + break; + } + } + int byDefault(AlbumTranslation a, AlbumTranslation b) => + (a.locale == album.defaultLocale ? 0 : 1).compareTo( + b.locale == album.defaultLocale ? 0 : 1, + ); + if (langMatch != null) { + final rest = album.translations.where((t) => t != langMatch).toList() + ..sort(byDefault); + return [langMatch, ...rest]; + } + return [...album.translations]..sort(byDefault); +} + +/// 展示标题(UI-01 §4.1 回退链;无翻译时相册 ID 兜底)。 +String albumDisplayTitle(AlbumSummary album, String localeName) => + albumDisplayTranslations(album, localeName).firstOrNull?.title ?? album.id; + +/// 展示场地(同一回退链上首个非空场地;全空返回 null,不占一整行空白)。 +String? albumDisplayVenue(AlbumSummary album, String localeName) { + for (final t in albumDisplayTranslations(album, localeName)) { + final venue = t.venue?.trim(); + if (venue != null && venue.isNotEmpty) return venue; + } + return null; } diff --git a/apps/mobile/lib/features/uploads/queue_controller.dart b/apps/mobile/lib/features/uploads/queue_controller.dart index 7129aac..6a12492 100644 --- a/apps/mobile/lib/features/uploads/queue_controller.dart +++ b/apps/mobile/lib/features/uploads/queue_controller.dart @@ -32,28 +32,26 @@ enum QueueStateFilter { /// 分组对应的状态集合(空 = 不过滤)。 Set get states => switch (this) { - QueueStateFilter.all => const {}, - QueueStateFilter.preparing => const { - UploadTaskState.preparing, - }, - QueueStateFilter.queued => const { - UploadTaskState.queued, - }, - QueueStateFilter.uploading => const { - UploadTaskState.uploading, - UploadTaskState.uploadedConfirming, - }, - QueueStateFilter.waitingNetwork => const { - UploadTaskState.waitingNetwork, - }, - QueueStateFilter.failed => const { - UploadTaskState.preparationFailed, - UploadTaskState.retryableFailed, - }, - QueueStateFilter.completed => const { - UploadTaskState.completed, - }, - }; + QueueStateFilter.all => const {}, + QueueStateFilter.preparing => const { + UploadTaskState.preparing, + }, + QueueStateFilter.queued => const {UploadTaskState.queued}, + QueueStateFilter.uploading => const { + UploadTaskState.uploading, + UploadTaskState.uploadedConfirming, + }, + QueueStateFilter.waitingNetwork => const { + UploadTaskState.waitingNetwork, + }, + QueueStateFilter.failed => const { + UploadTaskState.preparationFailed, + UploadTaskState.retryableFailed, + }, + QueueStateFilter.completed => const { + UploadTaskState.completed, + }, + }; } /// 时间筛选快捷(今天/近7天/近30天/全部)。 @@ -66,13 +64,11 @@ enum QueueTimeFilter { /// 创建时间下限(null = 不过滤;本地时刻,matches 内与 UTC 落库值 /// 比较前归一)。 DateTime? since(DateTime now) => switch (this) { - QueueTimeFilter.all => null, - QueueTimeFilter.today => DateTime(now.year, now.month, now.day), - QueueTimeFilter.last7Days => - now.subtract(const Duration(days: 7)), - QueueTimeFilter.last30Days => - now.subtract(const Duration(days: 30)), - }; + QueueTimeFilter.all => null, + QueueTimeFilter.today => DateTime(now.year, now.month, now.day), + QueueTimeFilter.last7Days => now.subtract(const Duration(days: 7)), + QueueTimeFilter.last30Days => now.subtract(const Duration(days: 30)), + }; } /// 三维筛选条件(不可变值对象;纯匹配逻辑单测覆盖)。 @@ -108,12 +104,11 @@ class QueueFilter { bool clearAlbum = false, QueueStateFilter? state, QueueTimeFilter? time, - }) => - QueueFilter( - albumId: clearAlbum ? null : (albumId ?? this.albumId), - state: state ?? this.state, - time: time ?? this.time, - ); + }) => QueueFilter( + albumId: clearAlbum ? null : (albumId ?? this.albumId), + state: state ?? this.state, + time: time ?? this.time, + ); } /// 筛选状态 Notifier。 @@ -121,17 +116,20 @@ class QueueFilterNotifier extends Notifier { @override QueueFilter build() => const QueueFilter(); - void setAlbum(String? albumId) => - state = albumId == null ? state.copyWith(clearAlbum: true) : state.copyWith(albumId: albumId); + void setAlbum(String? albumId) => state = albumId == null + ? state.copyWith(clearAlbum: true) + : state.copyWith(albumId: albumId); - void setStateGroup(QueueStateFilter group) => state = state.copyWith(state: group); + void setStateGroup(QueueStateFilter group) => + state = state.copyWith(state: group); void setTime(QueueTimeFilter time) => state = state.copyWith(time: time); } /// 筛选状态 Provider。 -final queueFilterProvider = - NotifierProvider(QueueFilterNotifier.new); +final queueFilterProvider = NotifierProvider( + QueueFilterNotifier.new, +); /// 防抖重查间隔(连拍/引擎迁移写风暴下限流)。 const Duration kQueueRefreshDebounce = Duration(milliseconds: 200); @@ -158,13 +156,12 @@ class QueueTasksNotifier extends AsyncNotifier> { TaskStore store, String account, QueueFilter filter, - ) => - store.listByAccount( - account, - albumId: filter.albumId, - states: filter.state.states.isEmpty ? null : filter.state.states, - since: filter.time.since(DateTime.now()), - ); + ) => store.listByAccount( + account, + albumId: filter.albumId, + states: filter.state.states.isEmpty ? null : filter.state.states, + since: filter.time.since(DateTime.now()), + ); /// 写通知 → 200ms 防抖后重查(同一账号/筛选的最新快照)。 void _scheduleReload() { @@ -172,8 +169,11 @@ class QueueTasksNotifier extends AsyncNotifier> { _debounce = Timer(kQueueRefreshDebounce, () { final account = ref.read(sessionAccountProvider).value; if (account == null) return; - _query(ref.read(taskStoreProvider), account, ref.read(queueFilterProvider)) - .then((tasks) => state = AsyncData>(tasks)); + _query( + ref.read(taskStoreProvider), + account, + ref.read(queueFilterProvider), + ).then((tasks) => state = AsyncData>(tasks)); }); } @@ -188,8 +188,8 @@ class QueueTasksNotifier extends AsyncNotifier> { /// 队列任务列表 Provider。 final queueTasksProvider = AsyncNotifierProvider>( - QueueTasksNotifier.new, -); + QueueTasksNotifier.new, + ); /// 账号内各状态计数(连拍页徽章/批量重试条;写通知防抖刷新)。 class TaskCountsNotifier extends Notifier> { @@ -234,8 +234,8 @@ class TaskCountsNotifier extends Notifier> { /// 各状态计数 Provider(键覆盖全部状态,无任务计 0)。 final taskCountsProvider = NotifierProvider>( - TaskCountsNotifier.new, -); + TaskCountsNotifier.new, + ); /// 未完成(待传)计数:准备中/待传/上传中/确认中/等待网络之和。 int pendingCountOf(Map counts) => @@ -249,3 +249,15 @@ int pendingCountOf(Map counts) => int failedCountOf(Map counts) => counts[UploadTaskState.preparationFailed]! + counts[UploadTaskState.retryableFailed]!; + +/// 「未结束」计数(底部导航「上传」角标,UI-01 §4.1):待传( +/// [pendingCountOf])+ 可重试失败——均为引擎待推进或用户需关注的活任务; +/// 准备失败/取消/完成不计入。账号隔离由 TaskStore.countsByState 硬守护。 +int unfinishedCountOf(Map counts) => + pendingCountOf(counts) + counts[UploadTaskState.retryableFailed]!; + +/// 导航角标计数 Provider:主壳 watch(计数不变不触发重建;页签状态由 +/// IndexedStack 保持,不随角标重建)。 +final Provider uploadBadgeCountProvider = Provider( + (ref) => unfinishedCountOf(ref.watch(taskCountsProvider)), +); diff --git a/apps/mobile/lib/features/uploads/queue_l10n.dart b/apps/mobile/lib/features/uploads/queue_l10n.dart index 03d36db..93e8fce 100644 --- a/apps/mobile/lib/features/uploads/queue_l10n.dart +++ b/apps/mobile/lib/features/uploads/queue_l10n.dart @@ -8,7 +8,6 @@ library; import '../../core/error_codes.dart'; import '../../core/error_l10n.dart'; import '../../l10n/app_localizations.dart'; -import '../albums/models.dart'; import 'preparer.dart'; import 'task_state.dart'; import 'upload_engine.dart'; @@ -16,16 +15,16 @@ import 'upload_engine.dart'; extension AppLocalizationsUploadX on AppLocalizations { /// 任务状态标签(chip/文案)。 String labelForTaskState(UploadTaskState state) => switch (state) { - UploadTaskState.preparing => taskStatePreparing, - UploadTaskState.preparationFailed => taskStatePreparationFailed, - UploadTaskState.queued => taskStateQueued, - UploadTaskState.uploading => taskStateUploading, - UploadTaskState.uploadedConfirming => taskStateUploadedConfirming, - UploadTaskState.completed => taskStateCompleted, - UploadTaskState.waitingNetwork => taskStateWaitingNetwork, - UploadTaskState.retryableFailed => taskStateRetryableFailed, - UploadTaskState.cancelled => taskStateCancelled, - }; + UploadTaskState.preparing => taskStatePreparing, + UploadTaskState.preparationFailed => taskStatePreparationFailed, + UploadTaskState.queued => taskStateQueued, + UploadTaskState.uploading => taskStateUploading, + UploadTaskState.uploadedConfirming => taskStateUploadedConfirming, + UploadTaskState.completed => taskStateCompleted, + UploadTaskState.waitingNetwork => taskStateWaitingNetwork, + UploadTaskState.retryableFailed => taskStateRetryableFailed, + UploadTaskState.cancelled => taskStateCancelled, + }; /// 任务 lastError → 展示文案(上传域伪码 + 契约码;未映射回退 /// error_l10n 的通用解析)。 @@ -52,24 +51,3 @@ extension AppLocalizationsUploadX on AppLocalizations { } } } - -/// 相册展示标题解析:当前 locale → 相册默认语言 → 首条 → 相册 ID。 -/// -/// locale 匹配按语言前缀(App locale `zh`/`en` vs 契约 `zh-CN`/`en`)。 -String albumDisplayTitle(AlbumSummary album, String localeName) { - final lang = localeName.split('-').first.toLowerCase(); - AlbumTranslation? match; - for (final t in album.translations) { - if (t.locale.toLowerCase() == localeName.toLowerCase() || - t.locale.toLowerCase().split('-').first == lang) { - match = t; - break; - } - } - if (match != null) return match.title; - for (final t in album.translations) { - if (t.locale == album.defaultLocale) return t.title; - } - if (album.translations.isNotEmpty) return album.translations.first.title; - return album.id; -} diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 7c95fb2..687c4b0 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -17,9 +17,14 @@ "cancel": "Cancel", "errorGeneric": "Something went wrong, please retry", "ok": "OK", - "homeTabAlbums": "Albums", - "homeTabQueue": "Queue", - "homeTabSettings": "Settings", + "navAlbums": "Albums", + "navUploads": "Uploads", + "navSettings": "Settings", + "badgeUploading": "{count} tasks", + "@badgeUploading": { "placeholders": { "count": { "type": "int" } } }, + "albumUploadsOn": "Uploads on", + "albumUploadsOff": "Uploads off", + "albumPublicOff": "Not public", "loginHint": "Sign in with your staff account", "fieldRequired": "This field is required", "passwordChangeTitle": "Change password", diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index c8dcf41..ee30318 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -200,23 +200,47 @@ abstract class AppLocalizations { /// **'好的'** String get ok; - /// No description provided for @homeTabAlbums. + /// No description provided for @navAlbums. /// /// In zh, this message translates to: /// **'相册'** - String get homeTabAlbums; + String get navAlbums; - /// No description provided for @homeTabQueue. + /// No description provided for @navUploads. /// /// In zh, this message translates to: - /// **'队列'** - String get homeTabQueue; + /// **'上传'** + String get navUploads; - /// No description provided for @homeTabSettings. + /// No description provided for @navSettings. /// /// In zh, this message translates to: /// **'设置'** - String get homeTabSettings; + String get navSettings; + + /// No description provided for @badgeUploading. + /// + /// In zh, this message translates to: + /// **'{count} 个任务'** + String badgeUploading(int count); + + /// No description provided for @albumUploadsOn. + /// + /// In zh, this message translates to: + /// **'开放上传'** + String get albumUploadsOn; + + /// No description provided for @albumUploadsOff. + /// + /// In zh, this message translates to: + /// **'上传已关闭'** + String get albumUploadsOff; + + /// No description provided for @albumPublicOff. + /// + /// In zh, this message translates to: + /// **'未公开'** + String get albumPublicOff; /// No description provided for @loginHint. /// diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 4b7d1db..bea1a92 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -60,13 +60,27 @@ class AppLocalizationsEn extends AppLocalizations { String get ok => 'OK'; @override - String get homeTabAlbums => 'Albums'; + String get navAlbums => 'Albums'; @override - String get homeTabQueue => 'Queue'; + String get navUploads => 'Uploads'; @override - String get homeTabSettings => 'Settings'; + String get navSettings => 'Settings'; + + @override + String badgeUploading(int count) { + return '$count tasks'; + } + + @override + String get albumUploadsOn => 'Uploads on'; + + @override + String get albumUploadsOff => 'Uploads off'; + + @override + String get albumPublicOff => 'Not public'; @override String get loginHint => 'Sign in with your staff account'; diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 435ea37..ad8b3e3 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -60,13 +60,27 @@ class AppLocalizationsZh extends AppLocalizations { String get ok => '好的'; @override - String get homeTabAlbums => '相册'; + String get navAlbums => '相册'; @override - String get homeTabQueue => '队列'; + String get navUploads => '上传'; @override - String get homeTabSettings => '设置'; + String get navSettings => '设置'; + + @override + String badgeUploading(int count) { + return '$count 个任务'; + } + + @override + String get albumUploadsOn => '开放上传'; + + @override + String get albumUploadsOff => '上传已关闭'; + + @override + String get albumPublicOff => '未公开'; @override String get loginHint => '使用工作人员账号登录'; diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 61df742..bee67b8 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -17,9 +17,14 @@ "cancel": "取消", "errorGeneric": "出错了,请重试", "ok": "好的", - "homeTabAlbums": "相册", - "homeTabQueue": "队列", - "homeTabSettings": "设置", + "navAlbums": "相册", + "navUploads": "上传", + "navSettings": "设置", + "badgeUploading": "{count} 个任务", + "@badgeUploading": { "placeholders": { "count": { "type": "int" } } }, + "albumUploadsOn": "开放上传", + "albumUploadsOff": "上传已关闭", + "albumPublicOff": "未公开", "loginHint": "使用工作人员账号登录", "fieldRequired": "此项为必填项", "passwordChangeTitle": "修改密码", diff --git a/apps/mobile/lib/pages/albums_tab.dart b/apps/mobile/lib/pages/albums_tab.dart index 80fcc31..9707d25 100644 --- a/apps/mobile/lib/pages/albums_tab.dart +++ b/apps/mobile/lib/pages/albums_tab.dart @@ -1,6 +1,8 @@ -/// 相册列表页签(P3 计划 Task 4):游标加载更多、下拉刷新、卡片标签行 -/// (状态 + 四开关)、时区感知的时间展示、公开令牌短显、FAB 新建; -/// 加载/空/错误态齐备。详情/编辑由卡片与列表级刷新联动。 +/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1 样板)。 +/// +/// 卡片顺序:封面(16:9,无封面中性占位)→ 标题(≤2 行,语言回退链)→ +/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。完整列表 +/// 交互(平板两列、错误重试等)随后续任务迭代。 library; import 'package:flutter/material.dart'; @@ -11,6 +13,11 @@ import '../features/albums/album_format.dart'; import '../features/albums/models.dart'; import '../features/albums/providers.dart'; import '../l10n/app_localizations.dart'; +import '../ui/components/empty_state.dart'; +import '../ui/components/error_state.dart'; +import '../ui/components/photo_thumbnail.dart'; +import '../ui/components/state_badge.dart'; +import '../ui/tokens.dart'; import 'album_detail_page.dart'; import 'album_form_page.dart'; @@ -59,12 +66,15 @@ class AlbumListBody extends ConsumerWidget { } if (state.items.isEmpty) { if (state.errorCode != null) { - return _ErrorView( + return ErrorState( message: l10n.messageForErrorCode(state.errorCode!), onRetry: notifier.refresh, ); } - return _EmptyView(message: l10n.albumsEmpty); + return EmptyState( + icon: Icons.photo_library_outlined, + title: l10n.albumsEmpty, + ); } final hasFooter = state.hasMore || state.loadMoreFailed; @@ -77,14 +87,16 @@ class AlbumListBody extends ConsumerWidget { if (index >= state.items.length) { if (state.loadMoreFailed) { return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16), + contentPadding: const EdgeInsets.symmetric( + horizontal: kSpace16, + ), leading: const Icon(Icons.error_outline), title: Text(l10n.loadMoreFailed), onTap: notifier.loadMore, ); } return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), + padding: EdgeInsets.symmetric(vertical: kSpace16), child: Center( child: SizedBox( height: 24, @@ -109,58 +121,11 @@ class AlbumListBody extends ConsumerWidget { } } -/// 首屏错误视图(重试按钮触发整页刷新)。 -class _ErrorView extends StatelessWidget { - const _ErrorView({required this.message, required this.onRetry}); - - final String message; - final Future Function() onRetry; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.cloud_off, size: 48), - const SizedBox(height: 12), - Text(message, textAlign: TextAlign.center), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh), - label: Text(l10n.retry), - ), - ], - ), - ); - } -} - -/// 空列表视图。 -class _EmptyView extends StatelessWidget { - const _EmptyView({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.photo_library_outlined, size: 48), - const SizedBox(height: 12), - Text(message, textAlign: TextAlign.center), - ], - ), - ); - } -} - -/// 相册卡片:默认语言标题、状态 + 四开关标签行、相册时区的时间区间、 -/// 公开令牌短显。 +/// 相册卡片(UI-01 §4.1 样板):封面 → 标题 → 时间/场地 → 状态徽章。 +/// +/// 标题按「界面语言 → 相册默认语言 → 首个条目」回退;时间用相册时区; +/// 公开/上传分别显示(关闭不等于不公开)。publicToken 与四开关堆叠已从 +/// 主卡片移除(完整配置在详情设置)。 class AlbumCard extends StatelessWidget { const AlbumCard({required this.album, super.key}); @@ -170,98 +135,78 @@ class AlbumCard extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); - final time = _timeRange(l10n); + final venue = albumDisplayVenue(album, l10n.localeName); + final meta = venue == null + ? _timeRange(l10n) + : '${_timeRange(l10n)} · $venue'; return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + margin: const EdgeInsets.symmetric( + horizontal: kSpace16, + vertical: kSpace8, + ), + clipBehavior: Clip.antiAlias, child: InkWell( - borderRadius: BorderRadius.circular(12), onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumDetailPage(albumId: album.id), ), ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9), + Padding( + padding: const EdgeInsets.all(kSpace16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Text( - _title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), + Text( + albumDisplayTitle(album, l10n.localeName), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium, ), - const SizedBox(width: 8), - _StatusChip(status: album.status), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 4, - children: [ - _SwitchChip(label: l10n.tagUpload, on: album.uploadEnabled), - _SwitchChip(label: l10n.tagPublic, on: album.publicEnabled), - _SwitchChip( - label: l10n.tagDownload, - on: album.downloadEnabled, + const SizedBox(height: kSpace4), + Text( + meta, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, ), - _SwitchChip( - label: l10n.tagOriginal, - on: album.originalDownloadEnabled, - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.schedule, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - time, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + const SizedBox(height: kSpace12), + Row( + children: [ + StateBadge( + variant: album.publicEnabled + ? BadgeVariant.info + : BadgeVariant.neutral, + label: album.publicEnabled + ? l10n.tagPublic + : l10n.albumPublicOff, + compact: true, ), - ), + const SizedBox(width: kSpace8), + StateBadge( + variant: album.uploadEnabled + ? BadgeVariant.success + : BadgeVariant.neutral, + label: album.uploadEnabled + ? l10n.albumUploadsOn + : l10n.albumUploadsOff, + compact: true, + ), + ], ), ], ), - const SizedBox(height: 2), - Text( - album.publicToken.length > 10 - ? '${album.publicToken.substring(0, 10)}…' - : album.publicToken, - style: theme.textTheme.bodySmall?.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), + ), + ], ), ), ); } - /// 默认语言标题(缺失时回退首个条目;无翻译时显示 ID 兜底)。 - String get _title { - for (final t in album.translations) { - if (t.locale == album.defaultLocale) return t.title; - } - if (album.translations.isNotEmpty) return album.translations.first.title; - return album.id; - } - /// 相册时区的时间区间(结束未定显示「未定」占位)。 String _timeRange(AppLocalizations l10n) { final locale = l10n.localeName; @@ -272,64 +217,3 @@ class AlbumCard extends StatelessWidget { return '$start ~ $end'; } } - -/// 状态标签:active 正常 / offline 下线。 -class _StatusChip extends StatelessWidget { - const _StatusChip({required this.status}); - - final AlbumStatus status; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final theme = Theme.of(context); - final active = status == AlbumStatus.active; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: active - ? theme.colorScheme.secondaryContainer - : theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - active ? l10n.albumStatusActive : l10n.albumStatusOffline, - style: theme.textTheme.labelSmall?.copyWith( - color: active - ? theme.colorScheme.onSecondaryContainer - : theme.colorScheme.onErrorContainer, - ), - ), - ); - } -} - -/// 开关标签:色调区分开/关。 -class _SwitchChip extends StatelessWidget { - const _SwitchChip({required this.label, required this.on}); - - final String label; - final bool on; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: on - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: on - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant, - ), - ), - ); - } -} diff --git a/apps/mobile/lib/pages/home_page.dart b/apps/mobile/lib/pages/home_page.dart index 90825fa..6a9b7a5 100644 --- a/apps/mobile/lib/pages/home_page.dart +++ b/apps/mobile/lib/pages/home_page.dart @@ -4,32 +4,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/locale_controller.dart'; import '../core/api_client.dart'; import '../features/uploads/engine_scope.dart'; +import '../features/uploads/queue_controller.dart'; import '../l10n/app_localizations.dart'; import 'albums_tab.dart'; import 'queue_tab.dart'; -/// 主壳:底部导航(相册 / 队列 / 设置)。 -class HomePage extends StatefulWidget { +/// 主壳:底部导航「相册 / 上传 / 设置」(UI-01 §4.1)。 +/// +/// IndexedStack 保持三页签状态;「上传」页签角标 = 当前账号未结束任务数 +/// ([unfinishedCountOf],TaskStore 账号硬隔离,不跨账号),为 0 时隐藏。 +class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); @override - State createState() => _HomePageState(); + ConsumerState createState() => _HomePageState(); } -class _HomePageState extends State { +class _HomePageState extends ConsumerState { int _selectedIndex = 0; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); + final badgeCount = ref.watch(uploadBadgeCountProvider); return Scaffold( body: IndexedStack( index: _selectedIndex, - children: const [ - AlbumsTab(), - QueueTab(), - SettingsTab(), - ], + children: const [AlbumsTab(), QueueTab(), SettingsTab()], ), bottomNavigationBar: NavigationBar( selectedIndex: _selectedIndex, @@ -39,17 +40,23 @@ class _HomePageState extends State { NavigationDestination( icon: const Icon(Icons.photo_library_outlined), selectedIcon: const Icon(Icons.photo_library), - label: l10n.homeTabAlbums, + label: l10n.navAlbums, ), NavigationDestination( - icon: const Icon(Icons.cloud_upload_outlined), - selectedIcon: const Icon(Icons.cloud_upload), - label: l10n.homeTabQueue, + icon: _UploadTabIcon( + count: badgeCount, + icon: Icons.cloud_upload_outlined, + ), + selectedIcon: _UploadTabIcon( + count: badgeCount, + icon: Icons.cloud_upload, + ), + label: l10n.navUploads, ), NavigationDestination( icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings), - label: l10n.homeTabSettings, + label: l10n.navSettings, ), ], ), @@ -57,6 +64,25 @@ class _HomePageState extends State { } } +/// 上传页签图标 + 未完成任务角标(≤0 时隐藏 Badge,仅图标)。 +/// +/// Semantics 携带本地化计数文案(读屏不依赖角标视觉)。 +class _UploadTabIcon extends StatelessWidget { + const _UploadTabIcon({required this.count, required this.icon}); + + final int count; + final IconData icon; + + @override + Widget build(BuildContext context) { + if (count <= 0) return Icon(icon); + return Semantics( + label: AppLocalizations.of(context).badgeUploading(count), + child: Badge.count(count: count, child: Icon(icon)), + ); + } +} + /// 设置页(P3 Task 3:修改密码入口 + 退出登录;Task 10:语言切换)。 class SettingsTab extends ConsumerWidget { const SettingsTab({super.key}); diff --git a/apps/mobile/lib/pages/queue_tab.dart b/apps/mobile/lib/pages/queue_tab.dart index e20ec9a..c885698 100644 --- a/apps/mobile/lib/pages/queue_tab.dart +++ b/apps/mobile/lib/pages/queue_tab.dart @@ -12,6 +12,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../features/albums/album_format.dart'; import '../features/albums/models.dart'; import '../features/albums/providers.dart'; import '../features/uploads/engine_scope.dart'; @@ -31,7 +32,8 @@ class QueueTab extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final counts = ref.watch(taskCountsProvider); - final autoRetryable = (counts[UploadTaskState.retryableFailed] ?? 0) + + final autoRetryable = + (counts[UploadTaskState.retryableFailed] ?? 0) + (counts[UploadTaskState.waitingNetwork] ?? 0); return Scaffold( appBar: AppBar( @@ -40,8 +42,7 @@ class QueueTab extends ConsumerWidget { IconButton( tooltip: l10n.serverHistoryEntry, icon: const Icon(Icons.history_outlined), - onPressed: () => - Navigator.of(context).pushNamed('/server-history'), + onPressed: () => Navigator.of(context).pushNamed('/server-history'), ), ], ), @@ -77,10 +78,15 @@ class _FilterBar extends ConsumerWidget { children: [ Row( children: [ - Icon(Icons.filter_list, - size: 18, color: theme.colorScheme.onSurfaceVariant), + Icon( + Icons.filter_list, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), const SizedBox(width: 8), - Expanded(child: _AlbumDropdown(filter: filter, albums: albums)), + Expanded( + child: _AlbumDropdown(filter: filter, albums: albums), + ), ], ), const SizedBox(height: 4), @@ -93,9 +99,8 @@ class _FilterBar extends ConsumerWidget { ChoiceChip( label: Text(_timeLabel(l10n, time)), selected: filter.time == time, - onSelected: (_) => ref - .read(queueFilterProvider.notifier) - .setTime(time), + onSelected: (_) => + ref.read(queueFilterProvider.notifier).setTime(time), visualDensity: VisualDensity.compact, ), ], @@ -159,10 +164,9 @@ class _AlbumDropdown extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final knownIds = albums.map((a) => a.id).toSet(); - final value = - filter.albumId != null && knownIds.contains(filter.albumId) - ? filter.albumId - : null; + final value = filter.albumId != null && knownIds.contains(filter.albumId) + ? filter.albumId + : null; return DropdownButton( value: value, isExpanded: true, @@ -224,8 +228,7 @@ class _TaskListView extends ConsumerWidget { return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: tasks.length, - itemBuilder: (context, index) => - _TaskTile(task: tasks[index]), + itemBuilder: (context, index) => _TaskTile(task: tasks[index]), ); }, ); @@ -250,7 +253,8 @@ class _TaskTile extends ConsumerWidget { final albumName = album != null ? albumDisplayTitle(album, l10n.localeName) : task.albumId; - final failedLike = task.state == UploadTaskState.preparationFailed || + final failedLike = + task.state == UploadTaskState.preparationFailed || task.state == UploadTaskState.retryableFailed || task.state == UploadTaskState.waitingNetwork; @@ -258,11 +262,7 @@ class _TaskTile extends ConsumerWidget { leading: _Thumb( path: task.cleanedAt == null ? task.sourceFilePath : null, ), - title: Text( - task.filename, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + title: Text(task.filename, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -276,8 +276,9 @@ class _TaskTile extends ConsumerWidget { albumName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ), ], @@ -287,12 +288,13 @@ class _TaskTile extends ConsumerWidget { Text( task.attempt > 0 ? '${l10n.messageForTaskError(task.lastError!)} · ' - '${l10n.queueAttemptCount(task.attempt)}' + '${l10n.queueAttemptCount(task.attempt)}' : l10n.messageForTaskError(task.lastError!), maxLines: 2, overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.error), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), ), ], if (task.state == UploadTaskState.uploading || @@ -318,18 +320,18 @@ class _Thumb extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); Widget placeholder() => Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.image_outlined, - size: 24, - color: theme.colorScheme.onSurfaceVariant, - ), - ); + width: 48, + height: 48, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.image_outlined, + size: 24, + color: theme.colorScheme.onSurfaceVariant, + ), + ); if (path == null) return placeholder(); return ClipRRect( borderRadius: BorderRadius.circular(6), @@ -358,35 +360,33 @@ class _StateChip extends StatelessWidget { final theme = Theme.of(context); final (color, onColor) = switch (state) { UploadTaskState.preparing => ( - theme.colorScheme.surfaceContainerHighest, - theme.colorScheme.onSurfaceVariant - ), - UploadTaskState.preparationFailed || - UploadTaskState.retryableFailed => ( - theme.colorScheme.errorContainer, - theme.colorScheme.onErrorContainer - ), + theme.colorScheme.surfaceContainerHighest, + theme.colorScheme.onSurfaceVariant, + ), + UploadTaskState.preparationFailed || UploadTaskState.retryableFailed => ( + theme.colorScheme.errorContainer, + theme.colorScheme.onErrorContainer, + ), UploadTaskState.queued => ( - theme.colorScheme.secondaryContainer, - theme.colorScheme.onSecondaryContainer - ), - UploadTaskState.uploading || - UploadTaskState.uploadedConfirming => ( - theme.colorScheme.primaryContainer, - theme.colorScheme.onPrimaryContainer - ), + theme.colorScheme.secondaryContainer, + theme.colorScheme.onSecondaryContainer, + ), + UploadTaskState.uploading || UploadTaskState.uploadedConfirming => ( + theme.colorScheme.primaryContainer, + theme.colorScheme.onPrimaryContainer, + ), UploadTaskState.completed => ( - theme.colorScheme.tertiaryContainer, - theme.colorScheme.onTertiaryContainer - ), + theme.colorScheme.tertiaryContainer, + theme.colorScheme.onTertiaryContainer, + ), UploadTaskState.waitingNetwork => ( - theme.colorScheme.tertiaryContainer, - theme.colorScheme.onTertiaryContainer - ), + theme.colorScheme.tertiaryContainer, + theme.colorScheme.onTertiaryContainer, + ), UploadTaskState.cancelled => ( - theme.colorScheme.surfaceContainerHighest, - theme.colorScheme.onSurfaceVariant - ), + theme.colorScheme.surfaceContainerHighest, + theme.colorScheme.onSurfaceVariant, + ), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), @@ -458,9 +458,7 @@ class _TaskActions extends ConsumerWidget { icon: const Icon(Icons.refresh), onPressed: account == null ? null - : () => ref - .read(uploadEngineProvider) - .retryNow(account, task.id), + : () => ref.read(uploadEngineProvider).retryNow(account, task.id), ); case UploadTaskState.queued: case UploadTaskState.preparing: @@ -495,15 +493,11 @@ class _TaskActions extends ConsumerWidget { if (sourcePath == null || !context.mounted) return; final messenger = ScaffoldMessenger.of(context); try { - await ref.read(uploadPreparerProvider).retry( - accountId: account, - taskId: task.id, - sourcePath: sourcePath, - ); + await ref + .read(uploadPreparerProvider) + .retry(accountId: account, taskId: task.id, sourcePath: sourcePath); } on Object { - messenger.showSnackBar( - SnackBar(content: Text(l10n.errorGeneric)), - ); + messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); } } @@ -541,13 +535,17 @@ class _BatchRetryBar extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row( children: [ - Icon(Icons.error_outline, color: theme.colorScheme.onErrorContainer), + Icon( + Icons.error_outline, + color: theme.colorScheme.onErrorContainer, + ), const SizedBox(width: 8), Expanded( child: Text( '${l10n.queueStateFailed} · $failed', - style: theme.textTheme.bodyMedium - ?.copyWith(color: theme.colorScheme.onErrorContainer), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), ), ), FilledButton.icon( @@ -572,7 +570,9 @@ class _BatchRetryBar extends ConsumerWidget { final account = ref.read(sessionAccountProvider).value; if (account == null) return; final engine = ref.read(uploadEngineProvider); - final tasks = await ref.read(taskStoreProvider).listByAccount( + final tasks = await ref + .read(taskStoreProvider) + .listByAccount( account, states: const { UploadTaskState.retryableFailed, diff --git a/apps/mobile/lib/ui/components/empty_state.dart b/apps/mobile/lib/ui/components/empty_state.dart new file mode 100644 index 0000000..771e396 --- /dev/null +++ b/apps/mobile/lib/ui/components/empty_state.dart @@ -0,0 +1,76 @@ +/// 空态(UI-01):图标 + 标题 + 说明 + 可选操作按钮,居中布局。 +/// +/// 用于列表/工作区「暂无内容」的引导(相册为空、队列为空等);标题与 +/// 说明由调用方传入(本地化),操作按钮为次强调(tonal,不与页面主 +/// CTA 争夺层级)。与 ErrorState 的区别:空态是正常状态,无重试语义。 +library; + +import 'package:flutter/material.dart'; + +import '../tokens.dart'; + +/// 空态视图。 +class EmptyState extends StatelessWidget { + const EmptyState({ + required this.icon, + required this.title, + this.subtitle, + this.actionLabel, + this.onAction, + super.key, + }); + + /// 主题图标(如 [Icons.photo_library_outlined])。 + final IconData icon; + + /// 主文案(如「暂无相册」)。 + final String title; + + /// 补充说明;null 不占位。 + final String? subtitle; + + /// 可选操作按钮文案;null 不显示按钮。 + final String? actionLabel; + + /// 操作回调(与 [actionLabel] 成对提供)。 + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(kSpace24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: kSpace12), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(height: kSpace4), + Text( + subtitle!, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + if (actionLabel != null) ...[ + const SizedBox(height: kSpace16), + FilledButton.tonal( + onPressed: onAction, + child: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/ui/components/error_state.dart b/apps/mobile/lib/ui/components/error_state.dart new file mode 100644 index 0000000..47201bb --- /dev/null +++ b/apps/mobile/lib/ui/components/error_state.dart @@ -0,0 +1,63 @@ +/// 错误态(UI-01):错误图标 + 说明 + 重试按钮(主强调)。 +/// +/// 用于加载失败等需要用户处理的场景。与 EmptyState 的区别:错误态有 +/// 明确的重试动作(主按钮 + 刷新图标),文案为错误色语义;空态无重试。 +/// 重试文案来自语言资源([AppLocalizations.retry])。 +library; + +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../tokens.dart'; + +/// 错误态视图。 +class ErrorState extends StatelessWidget { + const ErrorState({ + required this.message, + required this.onRetry, + this.icon, + super.key, + }); + + /// 错误说明(调用方本地化,如错误码映射文案)。 + final String message; + + /// 重试回调。 + final VoidCallback onRetry; + + /// 覆盖主题图标(默认 [Icons.cloud_off],加载失败语义)。 + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final errorColor = theme.brightness == Brightness.light + ? kColorError + : kColorErrorDark; + return Center( + child: Padding( + padding: const EdgeInsets.all(kSpace24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon ?? Icons.cloud_off, size: 48, color: errorColor), + const SizedBox(height: kSpace12), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: kSpace16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(AppLocalizations.of(context).retry), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/ui/components/photo_thumbnail.dart b/apps/mobile/lib/ui/components/photo_thumbnail.dart new file mode 100644 index 0000000..7f17ccd --- /dev/null +++ b/apps/mobile/lib/ui/components/photo_thumbnail.dart @@ -0,0 +1,67 @@ +/// 网络照片缩略图(UI-01 §4.1):固定宽高比预留 + 占位 + 失败替代图。 +/// +/// 布局稳定性优先:占位不是转圈,而是同一宽高比的中性色块(网格/卡片 +/// 不因加载状态跳动);加载完成前显示占位,失败显示中性色块 + 小图标 +/// (不显示破图)。[url] 为 null(无封面/已删除)时常驻占位。使用 +/// Image.network + errorBuilder(项目未引入 cached_network_image,保持 +/// 零新增依赖)。 +library; + +import 'package:flutter/material.dart'; + +/// 网络缩略图:`AspectRatio` 包裹,cover 裁切。 +class PhotoThumbnail extends StatelessWidget { + const PhotoThumbnail({ + required this.url, + required this.aspectRatio, + this.fit = BoxFit.cover, + super.key, + }); + + /// 图片 URL;null 显示常驻占位(无封面/无预览语义)。 + final String? url; + + /// 宽高比(相册封面 16:9、网格 1:1 等)——占位与图片共用,尺寸恒定。 + final double aspectRatio; + + /// 图片适配(默认 cover 裁切;大图查看用 contain)。 + final BoxFit fit; + + @override + Widget build(BuildContext context) { + if (url == null) { + return AspectRatio( + aspectRatio: aspectRatio, + child: _placeholder(context), + ); + } + return AspectRatio( + aspectRatio: aspectRatio, + child: Image.network( + url!, + fit: fit, + // 帧 ready 前显示中性占位(不是 spinner):布局稳定优先。 + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => + wasSynchronouslyLoaded || frame != null + ? child + : _placeholder(context), + errorBuilder: (_, _, _) => + _placeholder(context, icon: Icons.broken_image_outlined), + ), + ); + } + + /// 中性占位色块 + 小图标(加载中 / 无封面 / 加载失败共用)。 + Widget _placeholder( + BuildContext context, { + IconData icon = Icons.image_outlined, + }) { + final theme = Theme.of(context); + return ColoredBox( + color: theme.colorScheme.surfaceContainerHighest, + child: Center( + child: Icon(icon, size: 24, color: theme.colorScheme.onSurfaceVariant), + ), + ); + } +} diff --git a/apps/mobile/lib/ui/components/section_header.dart b/apps/mobile/lib/ui/components/section_header.dart new file mode 100644 index 0000000..cfd945e --- /dev/null +++ b/apps/mobile/lib/ui/components/section_header.dart @@ -0,0 +1,37 @@ +/// 分组标题(UI-01 §3.1:分组标题 17-18):标题 + 可选行尾动作。 +/// +/// 标题取主题 titleMedium(w600);行尾动作由调用方构建(如「查看全部」 +/// TextButton)。不自带内外边距——由页面布局决定(避免为每个 SizedBox +/// 建一层封装)。 +library; + +import 'package:flutter/material.dart'; + +/// 分组标题行。 +class SectionHeader extends StatelessWidget { + const SectionHeader({required this.title, this.trailing, super.key}); + + /// 分组标题(本地化文案)。 + final String title; + + /// 行尾动作(可选)。 + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium, + ), + ), + if (trailing != null) ...[const SizedBox(width: 8), trailing!], + ], + ); + } +} diff --git a/apps/mobile/lib/ui/components/state_badge.dart b/apps/mobile/lib/ui/components/state_badge.dart new file mode 100644 index 0000000..3ff1718 --- /dev/null +++ b/apps/mobile/lib/ui/components/state_badge.dart @@ -0,0 +1,112 @@ +/// 状态徽章(UI-01 §3.1):图标 + 文案 + 颜色三重表达,不只靠颜色。 +/// +/// 跨页面复用的语义变体(上传状态、公开/关闭等);文案由调用方传入 +/// (本地化),图标按变体内置、可显式覆盖。两档尺寸:compact 高 24 +/// (卡片角标行)、默认高 32(列表/详情行)。 +library; + +import 'package:flutter/material.dart'; + +import '../tokens.dart'; + +/// 徽章语义变体。 +enum BadgeVariant { + /// 成功/完成事实(绿)。 + success, + + /// 等待中(琥珀)。 + warning, + + /// 需要处理的错误(红)。 + error, + + /// 中性/关闭/未启用(灰)。 + neutral, + + /// 信息提示(主色蓝)。 + info, +} + +/// 状态徽章:`图标 + 文案`,浅底色块(语义色 12% 透明度)+ 语义色文字。 +class StateBadge extends StatelessWidget { + const StateBadge({ + required this.variant, + required this.label, + this.icon, + this.compact = false, + super.key, + }); + + /// 语义变体(决定图标与颜色)。 + final BadgeVariant variant; + + /// 展示文案(调用方本地化)。 + final String label; + + /// 显式覆盖图标(默认按变体内置)。 + final IconData? icon; + + /// 紧凑档:高 24、字号 12;默认高 32、字号 13。 + final bool compact; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final (defaultIcon, color) = _resolve(variant, theme.brightness); + return Container( + height: compact ? 24.0 : 32.0, + padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(kRadiusBadge), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon ?? defaultIcon, size: compact ? 14 : 16, color: color), + // 图标-文字光学间距(非布局节奏,不进令牌)。 + SizedBox(width: compact ? kSpace4 : 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + (compact + ? theme.textTheme.labelSmall + : theme.textTheme.bodySmall) + ?.copyWith(color: color, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + + /// 变体 → (图标, 语义色);深色主题取提亮变体保证对比度。 + static (IconData, Color) _resolve( + BadgeVariant variant, + Brightness brightness, + ) => switch (variant) { + BadgeVariant.success => + brightness == Brightness.light + ? (Icons.check_circle, kColorSuccess) + : (Icons.check_circle, kColorSuccessDark), + BadgeVariant.warning => + brightness == Brightness.light + ? (Icons.hourglass_top, kColorWaiting) + : (Icons.hourglass_top, kColorWaitingDark), + BadgeVariant.error => + brightness == Brightness.light + ? (Icons.error_outline, kColorError) + : (Icons.error_outline, kColorErrorDark), + BadgeVariant.neutral => + brightness == Brightness.light + ? (Icons.remove_circle_outline, kColorTextSecondary) + : (Icons.remove_circle_outline, kColorTextSecondaryDark), + BadgeVariant.info => + brightness == Brightness.light + ? (Icons.info_outline, kColorInfo) + : (Icons.info_outline, kColorInfoDark), + }; +} diff --git a/apps/mobile/lib/ui/tokens.dart b/apps/mobile/lib/ui/tokens.dart new file mode 100644 index 0000000..501ebc5 --- /dev/null +++ b/apps/mobile/lib/ui/tokens.dart @@ -0,0 +1,83 @@ +/// UI-01 设计令牌(方案 §3.1/§3.2):色彩、间距、圆角、动效时长。 +/// +/// - 语义色(成功/等待/错误)不占用 Material ColorScheme 角色(secondary +/// 虽承载等待色,但组件层统一走令牌),浅色值为方案基准;深色场景用 +/// `*Dark` 变体——深色表面上直接使用浅色基准会跌破对比度目标,组件按 +/// [Brightness] 选择(见 StateBadge)。 +/// - 间距 4/8/12/16/24/32;页面左右默认 [kSpace16]。 +/// - 圆角:卡片 16 / 输入框与按钮 12 / 小标签 8,避免全部做成胶囊。 +/// - 动效:轻量状态 [kMotionFast]、轮播/照片过渡 [kMotionCarousel]; +/// 系统「减少动态效果」开启时 [motion] 统一归零(禁用非必要动效)。 +library; + +import 'package:flutter/material.dart'; + +// ── 语义色(浅色基准,方案 §3.1)────────────────────────────────────────── + +/// 成功:已上传等完成事实(#16794B)。 +const Color kColorSuccess = Color(0xFF16794B); + +/// 等待:等待网络、等待调度(#996300)。 +const Color kColorWaiting = Color(0xFF996300); + +/// 错误:需要用户处理的失败(#B42318)。 +const Color kColorError = Color(0xFFB42318); + +/// 信息:中性提示(与主色一致,#1A73E8)。 +const Color kColorInfo = Color(0xFF1A73E8); + +/// 分隔线:轻分隔,不用粗边框(#E5EAF1)。 +const Color kColorDivider = Color(0xFFE5EAF1); + +/// 次文字:时间、地点、说明(#5E6878)。 +const Color kColorTextSecondary = Color(0xFF5E6878); + +// ── 语义色(深色变体:深色表面对应提亮值)────────────────────────────── + +/// 成功(深色表面用)。 +const Color kColorSuccessDark = Color(0xFF34D399); + +/// 等待(深色表面用)。 +const Color kColorWaitingDark = Color(0xFFFCD34D); + +/// 错误(深色表面用)。 +const Color kColorErrorDark = Color(0xFFF87171); + +/// 信息(深色表面用,与深色主色一致)。 +const Color kColorInfoDark = Color(0xFF8AB4F8); + +/// 次文字(深色表面用)。 +const Color kColorTextSecondaryDark = Color(0xFFA6B0C0); + +// ── 间距(4/8/12/16/24/32)─────────────────────────────────────────────── + +const double kSpace4 = 4.0; +const double kSpace8 = 8.0; +const double kSpace12 = 12.0; +const double kSpace16 = 16.0; +const double kSpace24 = 24.0; +const double kSpace32 = 32.0; + +// ── 圆角(卡片 16 / 输入框与按钮 12 / 小标签 8)──────────────────────── + +const double kRadiusCard = 16.0; +const double kRadiusInput = 12.0; +const double kRadiusBadge = 8.0; + +// ── 动效时长(轻量 150-220ms / 轮播 250-350ms)──────────────────────── + +/// 轻量状态过渡(选中态、chip 切换等)。 +const Duration kMotionFast = Duration(milliseconds: 180); + +/// 轮播/照片过渡。 +const Duration kMotionCarousel = Duration(milliseconds: 300); + +/// 「减少动态效果」开启时的时长(等价禁用非必要动效)。 +const Duration kMotionReduce = Duration.zero; + +/// 当前上下文的动效时长:默认 [kMotionFast],`carousel: true` 取 +/// [kMotionCarousel];系统「减少动态效果」开启时一律 [kMotionReduce]。 +Duration motion(BuildContext context, {bool carousel = false}) { + if (MediaQuery.disableAnimationsOf(context)) return kMotionReduce; + return carousel ? kMotionCarousel : kMotionFast; +} diff --git a/apps/mobile/test/app/theme_test.dart b/apps/mobile/test/app/theme_test.dart new file mode 100644 index 0000000..7dea6ad --- /dev/null +++ b/apps/mobile/test/app/theme_test.dart @@ -0,0 +1,174 @@ +/// UI-01 主题一致性测试(Task 3)。 +/// +/// 覆盖:浅色 ColorScheme 方案 §3.1 精确值、深色语义对应值、字阶 +/// (22/17/16-14/13-12)、组件主题(按钮高 48/圆角 12、输入圆角 12、 +/// Dialog 圆角 16、BottomSheet 顶部圆角 16、AppBar titleLarge)、动效 +/// 令牌(含「减少动态效果」归零)。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/app/theme.dart'; +import 'package:photo_live/ui/tokens.dart'; + +void main() { + group('浅色 ColorScheme(方案 §3.1 精确值)', () { + final scheme = AppTheme.light.colorScheme; + + test('基础色', () { + expect(scheme.primary, const Color(0xFF1A73E8)); + expect(scheme.onPrimary, const Color(0xFFFFFFFF)); + expect(scheme.surface, const Color(0xFFFFFFFF)); + expect(scheme.onSurface, const Color(0xFF172033)); + expect(scheme.secondary, const Color(0xFF996300), reason: '等待色'); + expect(scheme.error, const Color(0xFFB42318)); + // 成功色不进 ColorScheme(独立令牌)。 + expect(kColorSuccess, const Color(0xFF16794B)); + }); + + test('页面背景与分隔线/次文字', () { + expect(AppTheme.light.scaffoldBackgroundColor, const Color(0xFFF6F8FB)); + expect(AppTheme.light.dividerTheme.color, kColorDivider); + expect(kColorDivider, const Color(0xFFE5EAF1)); + expect(scheme.onSurfaceVariant, kColorTextSecondary); + expect(kColorTextSecondary, const Color(0xFF5E6878)); + }); + }); + + group('深色 ColorScheme(语义对应,不反转图片)', () { + final theme = AppTheme.dark; + final scheme = theme.colorScheme; + + test('深表面 + 浅文字 + 提亮主色', () { + expect(scheme.brightness, Brightness.dark); + expect(scheme.surface, const Color(0xFF1F2530)); + expect(scheme.onSurface, const Color(0xFFE6EAF2)); + expect(scheme.primary, kColorInfoDark); + expect(scheme.onPrimary, const Color(0xFF0B2E6F)); + expect(theme.scaffoldBackgroundColor, const Color(0xFF12161D)); + }); + + test('等待/错误为深色提亮变体', () { + expect(scheme.secondary, kColorWaitingDark); + expect(scheme.error, kColorErrorDark); + }); + }); + + group('字阶(随系统缩放:fontSize 走 TextScaler)', () { + final textTheme = AppTheme.light.textTheme; + + test('22 页面标题 / 17 分组 / 16-14 正文 / 13-12 辅助', () { + expect(textTheme.titleLarge!.fontSize, 22); + expect(textTheme.titleMedium!.fontSize, 17); + expect(textTheme.bodyLarge!.fontSize, 16); + expect(textTheme.bodyMedium!.fontSize, 14); + expect(textTheme.bodySmall!.fontSize, 13); + expect(textTheme.labelSmall!.fontSize, 12); + }); + + test('AppBar 标题用 titleLarge', () { + expect(AppTheme.light.appBarTheme.titleTextStyle!.fontSize, 22); + }); + }); + + group('组件主题', () { + final theme = AppTheme.light; + + test('按钮高 48、圆角 12', () { + final filled = theme.filledButtonTheme.style; + expect( + filled!.minimumSize!.resolve(const {}), + const Size(64, 48), + ); + expect( + theme.elevatedButtonTheme.style!.minimumSize!.resolve( + const {}, + ), + const Size(64, 48), + ); + final shape = + filled.shape!.resolve(const {}) + as RoundedRectangleBorder; + expect(shape.borderRadius, BorderRadius.circular(kRadiusInput)); + expect( + theme.outlinedButtonTheme.style!.minimumSize!.resolve( + const {}, + ), + const Size(48, 48), + ); + expect( + theme.textButtonTheme.style!.minimumSize!.resolve( + const {}, + ), + const Size(48, 48), + ); + }); + + test('输入框圆角 12', () { + final border = theme.inputDecorationTheme.border as OutlineInputBorder; + expect(border.borderRadius, BorderRadius.circular(kRadiusInput)); + expect(kRadiusInput, 12); + }); + + test('Dialog 圆角 16、BottomSheet 顶部圆角 16', () { + final dialog = theme.dialogTheme.shape as RoundedRectangleBorder; + expect(dialog.borderRadius, BorderRadius.circular(kRadiusCard)); + final sheet = theme.bottomSheetTheme.shape as RoundedRectangleBorder; + expect( + (sheet.borderRadius as BorderRadius).topLeft, + const Radius.circular(kRadiusCard), + ); + expect( + (sheet.borderRadius as BorderRadius).topRight, + const Radius.circular(kRadiusCard), + ); + expect(kRadiusCard, 16); + }); + + test('底部导航指示器色已配置', () { + expect( + theme.navigationBarTheme.indicatorColor, + theme.colorScheme.secondaryContainer, + ); + }); + }); + + group('动效令牌', () { + testWidgets('默认轻量 180ms / 轮播 300ms / 减少动态效果归零', (tester) async { + expect(kMotionFast, const Duration(milliseconds: 180)); + expect(kMotionCarousel, const Duration(milliseconds: 300)); + expect(kMotionReduce, Duration.zero); + + late BuildContext captured; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + captured = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(motion(captured), kMotionFast); + expect(motion(captured, carousel: true), kMotionCarousel); + + // 「减少动态效果」开启 → 一律归零。 + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: Builder( + builder: (context) { + captured = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + expect(motion(captured), kMotionReduce); + expect(motion(captured, carousel: true), kMotionReduce); + }); + }); +} diff --git a/apps/mobile/test/helpers/fakes.dart b/apps/mobile/test/helpers/fakes.dart index d572f86..33d2123 100644 --- a/apps/mobile/test/helpers/fakes.dart +++ b/apps/mobile/test/helpers/fakes.dart @@ -145,17 +145,14 @@ class FakeAdapter implements HttpClientAdapter { Map loginOkBody({ String token = 'tok-new', String expiresAt = '2026-09-20T12:00:00Z', -}) => { - 'sessionToken': token, - 'expiresAt': expiresAt, - }; +}) => {'sessionToken': token, 'expiresAt': expiresAt}; /// 常用响应体:契约 Error 形状。 Map errorBody(String code) => { - 'code': code, - 'message': 'stub message', - 'requestId': 'req-stub', - }; + 'code': code, + 'message': 'stub message', + 'requestId': 'req-stub', +}; // ── 相册域 fixtures(P3 计划 Task 4;形状对齐 openapi.yaml schemas)── @@ -175,44 +172,45 @@ Map albumJson({ bool originalDownloadEnabled = false, List>? translations, List> categories = const >[], -}) => - { - 'id': id, - 'publicToken': publicToken, - 'ownerId': ownerId, - 'status': status, - 'defaultLocale': defaultLocale, - 'timezone': timezone, - 'startAt': startAt, - 'endAt': endAt, - 'uploadEnabled': uploadEnabled, - 'publicEnabled': publicEnabled, - 'downloadEnabled': downloadEnabled, - 'originalDownloadEnabled': originalDownloadEnabled, - 'watermarkVersion': 0, - 'translations': translations ?? - >[ - { - 'locale': 'zh-CN', - 'title': '开幕晚宴', - 'description': null, - 'venue': '大宴会厅', - 'address': null, - }, - { - 'locale': 'en', - 'title': 'Opening Gala', - 'description': null, - 'venue': null, - 'address': null, - }, - ], - 'categories': categories, - 'createdAt': '2026-09-01T00:00:00Z', - 'updatedAt': '2026-09-01T00:00:00Z', - 'statsSyncedAt': null, - 'cover': null, - }; + Map? cover, +}) => { + 'id': id, + 'publicToken': publicToken, + 'ownerId': ownerId, + 'status': status, + 'defaultLocale': defaultLocale, + 'timezone': timezone, + 'startAt': startAt, + 'endAt': endAt, + 'uploadEnabled': uploadEnabled, + 'publicEnabled': publicEnabled, + 'downloadEnabled': downloadEnabled, + 'originalDownloadEnabled': originalDownloadEnabled, + 'watermarkVersion': 0, + 'translations': + translations ?? + >[ + { + 'locale': 'zh-CN', + 'title': '开幕晚宴', + 'description': null, + 'venue': '大宴会厅', + 'address': null, + }, + { + 'locale': 'en', + 'title': 'Opening Gala', + 'description': null, + 'venue': null, + 'address': null, + }, + ], + 'categories': categories, + 'createdAt': '2026-09-01T00:00:00Z', + 'updatedAt': '2026-09-01T00:00:00Z', + 'statsSyncedAt': null, + 'cover': cover, +}; /// 契约 `Category` 响应体。 Map categoryJson({ @@ -220,17 +218,17 @@ Map categoryJson({ String albumId = 'alb-1', int sortOrder = 0, List>? translations, -}) => - { - 'id': id, - 'albumId': albumId, - 'sortOrder': sortOrder, - 'translations': translations ?? - >[ - {'locale': 'zh-CN', 'name': '仪式'}, - {'locale': 'en', 'name': 'Ceremony'}, - ], - }; +}) => { + 'id': id, + 'albumId': albumId, + 'sortOrder': sortOrder, + 'translations': + translations ?? + >[ + {'locale': 'zh-CN', 'name': '仪式'}, + {'locale': 'en', 'name': 'Ceremony'}, + ], +}; /// 契约 `Member` 响应体。 Map memberJson({ @@ -238,26 +236,24 @@ Map memberJson({ String account = 'staff02', String displayName = 'Staff Two', String role = 'collaborator', -}) => - { - 'userId': userId, - 'account': account, - 'displayName': displayName, - 'role': role, - 'status': 'active', - }; +}) => { + 'userId': userId, + 'account': account, + 'displayName': displayName, + 'role': role, + 'status': 'active', +}; /// 契约 `AdminUserLookupItem` 响应体。 Map lookupUserJson({ String id = 'u-9', String account = 'staff09', String displayName = 'Staff Nine', -}) => - { - 'id': id, - 'account': account, - 'displayName': displayName, - }; +}) => { + 'id': id, + 'account': account, + 'displayName': displayName, +}; /// 契约 `AdminPhoto` 响应体。 Map adminPhotoJson({ @@ -266,36 +262,34 @@ Map adminPhotoJson({ String visibility = 'published', String processingState = 'ready', int bytes = 123456, -}) => - { - 'photoId': photoId, - 'albumId': albumId, - 'categoryId': null, - 'uploaderId': 'u-2', - 'visibility': visibility, - 'processingState': processingState, - 'bytes': bytes, - 'width': 4000, - 'height': 3000, - 'shotAt': null, - 'uploadedAt': '2026-09-18T07:00:00Z', - 'publishedAt': '2026-09-18T07:05:00Z', - 'likeCount': 0, - 'uploader': { - 'userId': 'u-2', - 'account': 'staff02', - 'displayName': 'Staff Two', - }, - 'preview': null, - }; +}) => { + 'photoId': photoId, + 'albumId': albumId, + 'categoryId': null, + 'uploaderId': 'u-2', + 'visibility': visibility, + 'processingState': processingState, + 'bytes': bytes, + 'width': 4000, + 'height': 3000, + 'shotAt': null, + 'uploadedAt': '2026-09-18T07:00:00Z', + 'publishedAt': '2026-09-18T07:05:00Z', + 'likeCount': 0, + 'uploader': { + 'userId': 'u-2', + 'account': 'staff02', + 'displayName': 'Staff Two', + }, + 'preview': null, +}; /// 契约游标分页响应体(hasMore 与 nextCursor 成对)。 Map cursorPageBody( List> items, { String? nextCursor, -}) => - { - 'items': items, - 'nextCursor': nextCursor, - 'hasMore': nextCursor != null, - }; +}) => { + 'items': items, + 'nextCursor': nextCursor, + 'hasMore': nextCursor != null, +}; diff --git a/apps/mobile/test/pages/albums_tab_test.dart b/apps/mobile/test/pages/albums_tab_test.dart index e9c5d85..175fe49 100644 --- a/apps/mobile/test/pages/albums_tab_test.dart +++ b/apps/mobile/test/pages/albums_tab_test.dart @@ -1,7 +1,8 @@ -/// 相册列表页签 widget 测试(P3 计划 Task 4)。 +/// 相册列表页签 widget 测试(P3 Task 4;UI-01 Task 3 起覆盖封面卡片样板)。 /// -/// 覆盖:首屏加载与卡片标签行(状态/开关/时区时间/令牌短显)、游标 -/// 加载更多(第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。 +/// 覆盖:封面卡片样板(封面占位/标题回退/时间场地行/公开与上传徽章/ +/// 令牌与四开关移除)、关闭状态徽章、封面加载失败替代图、游标加载更多 +/// (第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。 library; import 'package:dio/dio.dart'; @@ -13,6 +14,7 @@ import 'package:photo_live/core/error_codes.dart'; import 'package:photo_live/features/albums/providers.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/albums_tab.dart'; +import 'package:photo_live/ui/components/photo_thumbnail.dart'; import '../helpers/fakes.dart'; @@ -24,10 +26,10 @@ void main() { }); ApiClient clientOf(FakeAdapter adapter) => ApiClient( - sessionStore: store, - dio: Dio(BaseOptions(baseUrl: 'http://test.local')) - ..httpClientAdapter = adapter, - ); + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); Future pumpTab(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( @@ -43,35 +45,62 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('首屏加载 → 卡片渲染标题/状态/开关标签/时区时间/令牌短显', - (tester) async { + testWidgets('封面卡片样板:封面占位 + 标题回退 + 时间场地 + 公开/上传徽章', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums' - ? (200, cursorPageBody([albumJson( - status: 'offline', - uploadEnabled: true, - publicEnabled: false, - downloadEnabled: true, - originalDownloadEnabled: false, - )])) + ? (200, cursorPageBody([albumJson()])) : throw StateError('unexpected ${o.path}'), ); await pumpTab(tester, clientOf(adapter)); - expect(find.text('开幕晚宴'), findsOneWidget); - expect(find.text('Offline'), findsOneWidget); - // 四个开关标签(色调区分开关,文案一致)。 - expect(find.text('Upload'), findsOneWidget); + // 标题按界面语言回退(en → en 翻译),最多两行。 + expect(find.text('Opening Gala'), findsOneWidget); + // 时间用相册时区(上海墙钟 14:30)+ 场地(en 未填场地回退 zh-CN)。 + expect(find.text('2026/9/18 14:30 ~ Not set · 大宴会厅'), findsOneWidget); + // 封面未配置 → 固定 16:9 中性占位(非转圈)。 + expect(find.byType(PhotoThumbnail), findsOneWidget); + expect(find.byIcon(Icons.image_outlined), findsOneWidget); + // 公开/上传状态徽章(图标 + 文案,不只靠颜色)。 expect(find.text('Public'), findsOneWidget); - expect(find.text('Download'), findsOneWidget); - expect(find.text('Original'), findsOneWidget); - // Asia/Shanghai 墙钟 14:30;结束未定。 - expect(find.text('2026/9/18 14:30 ~ Not set'), findsOneWidget); - // 令牌短显(前 10 字符 + 省略号)。 - expect(find.text('sharetoken…'), findsOneWidget); + expect(find.byIcon(Icons.info_outline), findsOneWidget); + expect(find.text('Uploads on'), findsOneWidget); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + // publicToken 与四开关堆叠已从主卡片移除(完整配置在详情设置)。 + expect(find.text('sharetoken…'), findsNothing); + expect(find.text('Download'), findsNothing); + expect(find.text('Original'), findsNothing); expect(find.byIcon(Icons.add), findsOneWidget, reason: 'FAB 新建入口'); }); + testWidgets('关闭状态徽章与封面加载失败替代图', (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? ( + 200, + cursorPageBody([ + albumJson( + publicEnabled: false, + uploadEnabled: false, + cover: { + 'url': 'http://test.local/cover.jpg', + 'expiresAt': '2026-09-22T00:00:00Z', + }, + ), + ]), + ) + : throw StateError('unexpected ${o.path}'), + ); + await pumpTab(tester, clientOf(adapter)); + + // 公开关闭 / 上传关闭 → 中性徽章。 + expect(find.text('Not public'), findsOneWidget); + expect(find.text('Uploads off'), findsOneWidget); + // 封面 URL 加载失败(测试环境网络请求 400)→ 中性块 + 小图标,不显示破图。 + expect(find.byType(PhotoThumbnail), findsOneWidget); + expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget); + expect(find.byIcon(Icons.image_outlined), findsNothing); + }); + testWidgets('滚动接近底部 → 游标加载第二页(携带 cursor)', (tester) async { final page1 = List>.generate( 20, @@ -81,15 +110,13 @@ void main() { 10, (i) => albumJson(id: 'alb-${i + 21}'), ); - final adapter = FakeAdapter( - (o) { - if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}'); - final cursor = o.uri.queryParameters['cursor']; - if (cursor == null) return (200, cursorPageBody(page1, nextCursor: 'c1')); - if (cursor == 'c1') return (200, cursorPageBody(page2)); - throw StateError('unexpected cursor $cursor'); - }, - ); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}'); + final cursor = o.uri.queryParameters['cursor']; + if (cursor == null) return (200, cursorPageBody(page1, nextCursor: 'c1')); + if (cursor == 'c1') return (200, cursorPageBody(page2)); + throw StateError('unexpected cursor $cursor'); + }); await pumpTab(tester, clientOf(adapter)); // 首屏只拉第一页(20 条入 state;ListView 懒构建,不逐条断言渲染)。 @@ -99,8 +126,11 @@ void main() { ); expect(container.read(albumListProvider).items, hasLength(20)); - // 上滑触发加载更多。 - await tester.drag(find.byType(ListView), const Offset(0, -2400)); + // 上滑触发加载更多(封面卡片较高,分段滚动直至触发第二页请求)。 + for (var i = 0; i < 10 && adapter.requests.length < 2; i++) { + await tester.drag(find.byType(ListView), const Offset(0, -1200)); + await tester.pump(); + } await tester.pumpAndSettle(); expect(adapter.requests, hasLength(2)); @@ -132,16 +162,14 @@ void main() { testWidgets('首屏错误 → 错误视图,重试后恢复', (tester) async { var failFirst = true; - final adapter = FakeAdapter( - (o) { - if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}'); - if (failFirst) { - failFirst = false; - return (500, errorBody(ErrorCodes.internalError)); - } - return (200, cursorPageBody([albumJson()])); - }, - ); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}'); + if (failFirst) { + failFirst = false; + return (500, errorBody(ErrorCodes.internalError)); + } + return (200, cursorPageBody([albumJson()])); + }); await pumpTab(tester, clientOf(adapter)); expect( @@ -152,6 +180,6 @@ void main() { await tester.tap(find.text('Retry')); await tester.pumpAndSettle(); - expect(find.text('开幕晚宴'), findsOneWidget); + expect(find.text('Opening Gala'), findsOneWidget); }); } diff --git a/apps/mobile/test/pages/home_badge_test.dart b/apps/mobile/test/pages/home_badge_test.dart new file mode 100644 index 0000000..9e46908 --- /dev/null +++ b/apps/mobile/test/pages/home_badge_test.dart @@ -0,0 +1,194 @@ +/// 底部导航与「上传」角标测试(UI-01 Task 3,§4.1)。 +/// +/// 覆盖:三页签改名(Albums/Uploads/Settings)与 IndexedStack 状态保持、 +/// 角标 = 当前账号未结束任务数(countsByState 聚合,写通知防抖刷新)、 +/// 他账号任务不计入(账号隔离)、任务全部结束后角标隐藏、 +/// [unfinishedCountOf] 纯函数口径(含 retryableFailed)。 +library; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/app/theme.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/features/uploads/queue_controller.dart'; +import 'package:photo_live/features/uploads/task_state.dart'; +import 'package:photo_live/features/uploads/task_store.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/albums_tab.dart'; +import 'package:photo_live/pages/home_page.dart'; +import 'package:photo_live/pages/queue_tab.dart'; + +import '../helpers/fakes.dart'; + +UploadTaskDraft _draft(String id, {String account = 'staff01'}) => + UploadTaskDraft( + id: id, + accountId: account, + albumId: 'alb-1', + clientTaskId: 'ct-$id', + sourceFilePath: '/tmp/$id.jpg', + filename: '$id.jpg', + bytes: 10, + contentType: 'image/jpeg', + ); + +/// 沿合法状态机单步迁移(自迁移非法,须按序调用)。 +Future _transition(TaskStore store, String id, UploadTaskState to) => + store.updateState(id, to); + +void main() { + late FakeSessionStore session; + late TaskStore taskStore; + + setUp(() { + session = FakeSessionStore() + ..session = Session( + token: 'tok-live-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'staff01', + ); + taskStore = TaskStore.memory(); + }); + + Future pumpHome(WidgetTester tester) async { + // 相册列表页签进入即拉列表;空页兜底即可。 + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? (200, cursorPageBody(>[])) + : throw StateError('unexpected ${o.path}'), + ); + final client = ApiClient( + sessionStore: session, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...pageTestOverrides( + session: session, + taskStore: taskStore, + client: client, + ), + ], + child: MaterialApp( + theme: AppTheme.light, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const HomePage(), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// 任务库写通知 → 200ms 防抖重查(kQueueRefreshDebounce)。 + /// + /// 不用 pumpAndSettle:uploading 任务的行级进度条为不确定态(常驻动画)。 + Future pumpCounts(WidgetTester tester) async { + await tester.pump(kQueueRefreshDebounce * 2); + await tester.pump(const Duration(milliseconds: 50)); + } + + testWidgets('三页签改名 + IndexedStack 保持页面状态', (tester) async { + await pumpHome(tester); + + // IndexedStack 常驻三页签(非当前页签 offstage,find 默认跳过,故显式 + // 关闭 skipOffstage)。Albums/Settings 各两次(页签 AppBar 标题 + 导航 + // 标签);Uploads 仅导航标签(队列页标题为 Upload queue)。 + expect(find.text('Albums', skipOffstage: false), findsNWidgets(2)); + expect(find.text('Uploads', skipOffstage: false), findsOneWidget); + expect(find.text('Settings', skipOffstage: false), findsNWidgets(2)); + expect( + find.text('Queue', skipOffstage: false), + findsNothing, + reason: '旧页签名已移除', + ); + + expect(find.byType(IndexedStack), findsOneWidget); + expect(find.byType(AlbumsTab, skipOffstage: false), findsOneWidget); + expect(find.byType(QueueTab, skipOffstage: false), findsOneWidget); + expect(find.byType(Badge), findsNothing, reason: '初始无任务无角标'); + }); + + testWidgets('角标 = 当前账号未完成任务数;他账号任务不计入', (tester) async { + await pumpHome(tester); + + // 其他账号任务:账号隔离,不显示角标。 + final other = await taskStore.create(_draft('t-o1', account: 'staff02')); + expect(other.accountId, 'staff02'); + await pumpCounts(tester); + expect(find.byType(Badge), findsNothing, reason: '他账号任务不计入'); + + // 本账号 3 个未完成(2 queued + 1 uploading)。 + final t1 = await taskStore.create(_draft('t-1')); + final t2 = await taskStore.create(_draft('t-2')); + final t3 = await taskStore.create(_draft('t-3')); + await _transition(taskStore, t1.id, UploadTaskState.queued); + await _transition(taskStore, t2.id, UploadTaskState.queued); + await _transition(taskStore, t3.id, UploadTaskState.queued); + await _transition(taskStore, t3.id, UploadTaskState.uploading); + await pumpCounts(tester); + + expect(find.byType(Badge), findsOneWidget); + expect(find.text('3'), findsOneWidget, reason: '角标计数 3'); + }); + + testWidgets('任务全部结束 → 角标隐藏', (tester) async { + await pumpHome(tester); + + final tasks = [ + for (var i = 1; i <= 2; i++) (await taskStore.create(_draft('t-$i'))).id, + ]; + for (final id in tasks) { + await _transition(taskStore, id, UploadTaskState.queued); + } + await pumpCounts(tester); + expect(find.text('2'), findsOneWidget); + + for (final id in tasks) { + await _transition(taskStore, id, UploadTaskState.uploading); + await _transition(taskStore, id, UploadTaskState.uploadedConfirming); + await _transition(taskStore, id, UploadTaskState.completed); + } + await pumpCounts(tester); + + expect(find.byType(Badge), findsNothing, reason: '0 隐藏角标'); + }); + + test('unfinishedCountOf 口径:待传 + 等待网络 + 可重试失败', () { + Map counts(Map patch) => + { + for (final state in UploadTaskState.values) state: patch[state] ?? 0, + }; + // 待传口径(pendingCountOf)之外,可重试失败计入;准备失败/取消/完成不计。 + expect( + unfinishedCountOf( + counts({UploadTaskState.queued: 2, UploadTaskState.uploading: 1}), + ), + 3, + ); + expect( + unfinishedCountOf( + counts({ + UploadTaskState.retryableFailed: 1, + UploadTaskState.waitingNetwork: 2, + }), + ), + 3, + ); + expect( + unfinishedCountOf( + counts({ + UploadTaskState.completed: 5, + UploadTaskState.cancelled: 1, + UploadTaskState.preparationFailed: 1, + }), + ), + 0, + ); + }); +} diff --git a/apps/mobile/test/ui/empty_state_test.dart b/apps/mobile/test/ui/empty_state_test.dart new file mode 100644 index 0000000..3a33ef7 --- /dev/null +++ b/apps/mobile/test/ui/empty_state_test.dart @@ -0,0 +1,60 @@ +/// EmptyState widget 测试(UI-01 Task 3)。 +/// +/// 覆盖:图标 + 标题 + 说明 + 可选操作按钮全量渲染;操作回调触发; +/// 无说明/无按钮时不占位。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/app/theme.dart'; +import 'package:photo_live/ui/components/empty_state.dart'; + +void main() { + Future pumpState( + WidgetTester tester, { + String? subtitle, + String? actionLabel, + VoidCallback? onAction, + }) { + return tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: EmptyState( + icon: Icons.photo_library_outlined, + title: 'No albums yet', + subtitle: subtitle, + actionLabel: actionLabel, + onAction: onAction, + ), + ), + ), + ); + } + + testWidgets('渲染图标 + 标题 + 说明 + 操作按钮', (tester) async { + var taps = 0; + await pumpState( + tester, + subtitle: 'Create one to get started', + actionLabel: 'Create album', + onAction: () => taps++, + ); + + expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget); + expect(find.text('No albums yet'), findsOneWidget); + expect(find.text('Create one to get started'), findsOneWidget); + expect(find.text('Create album'), findsOneWidget); + + await tester.tap(find.text('Create album')); + expect(taps, 1, reason: '操作回调触发'); + }); + + testWidgets('无说明/无按钮 → 仅图标与标题', (tester) async { + await pumpState(tester); + + expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget); + expect(find.text('No albums yet'), findsOneWidget); + expect(find.byType(FilledButton), findsNothing); + }); +} diff --git a/apps/mobile/test/ui/error_state_test.dart b/apps/mobile/test/ui/error_state_test.dart new file mode 100644 index 0000000..ba4d4f2 --- /dev/null +++ b/apps/mobile/test/ui/error_state_test.dart @@ -0,0 +1,61 @@ +/// ErrorState widget 测试(UI-01 Task 3)。 +/// +/// 覆盖:错误图标 + 说明 + 重试按钮(主强调,与 EmptyState 区分); +/// 重试回调触发。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/app/theme.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/ui/components/error_state.dart'; +import 'package:photo_live/ui/tokens.dart'; + +void main() { + Future pumpState(WidgetTester tester, VoidCallback onRetry) { + return tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ErrorState( + message: 'Network error. Please retry', + onRetry: onRetry, + ), + ), + ), + ); + } + + testWidgets('渲染错误图标 + 说明 + 重试按钮,回调触发', (tester) async { + var retries = 0; + await pumpState(tester, () => retries++); + + expect(find.byIcon(Icons.cloud_off), findsOneWidget, reason: '错误语义图标'); + expect(find.text('Network error. Please retry'), findsOneWidget); + final retryButton = find.widgetWithText(FilledButton, 'Retry'); + expect(retryButton, findsOneWidget, reason: '重试为主强调按钮'); + expect(find.byIcon(Icons.refresh), findsOneWidget); + + await tester.tap(retryButton); + expect(retries, 1, reason: '重试回调触发'); + }); + + testWidgets('深色主题错误图标用提亮变体', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: ErrorState(message: 'boom', onRetry: _noop), + ), + ), + ); + final icon = tester.widget(find.byIcon(Icons.cloud_off)); + expect(icon.color, kColorErrorDark); + }); +} + +void _noop() {} diff --git a/apps/mobile/test/ui/photo_thumbnail_test.dart b/apps/mobile/test/ui/photo_thumbnail_test.dart new file mode 100644 index 0000000..6cbc4de --- /dev/null +++ b/apps/mobile/test/ui/photo_thumbnail_test.dart @@ -0,0 +1,196 @@ +/// PhotoThumbnail widget 测试(UI-01 Task 3)。 +/// +/// 覆盖:无 URL 常驻中性占位(固定宽高比)、加载中立即显示占位(非 +/// 转圈,网格布局稳定)、失败替代图(中性块 + 小图标,不显示破图)、 +/// 加载成功显示图片(HttpOverrides 注入 1x1 PNG)。 +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/app/theme.dart'; +import 'package:photo_live/ui/components/photo_thumbnail.dart'; + +/// 1x1 透明 PNG(真实可解码字节)。 +final Uint8List kPngBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ' + 'AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', +); + +void main() { + Future pumpThumb( + WidgetTester tester, { + String? url, + double aspectRatio = 16 / 9, + }) { + return tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: Center( + child: PhotoThumbnail(url: url, aspectRatio: aspectRatio), + ), + ), + ), + ); + } + + testWidgets('url 为 null → 常驻中性占位(固定宽高比)', (tester) async { + await pumpThumb(tester); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.image_outlined), findsOneWidget); + expect(find.byIcon(Icons.broken_image_outlined), findsNothing); + + // 固定宽高比:视口 800x600 → 800 x 450。 + final size = tester.getSize(find.byType(PhotoThumbnail)); + expect(size.width, 800); + expect(size.height, 450); + }); + + testWidgets('加载中 → 立即占位;失败 → 中性块 + 小图标(不显示破图)', (tester) async { + // 测试环境默认 HttpClient 一律 400 → NetworkImage 失败路径。 + await pumpThumb(tester, url: 'http://test.local/cover.jpg'); + + // 首帧立即显示占位(非转圈;布局稳定)。 + expect(find.byIcon(Icons.image_outlined), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget); + expect(find.byIcon(Icons.image_outlined), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('加载成功 → 图片显示、占位消失', (tester) async { + // debug 钩子注入假 HttpClient:以真实 1x1 PNG 字节应答全部 GET + // (覆盖 flutter_test 默认的网络 400 mock)。测试体内复位——painting + // 调试变量须在框架不变量校验前归位(addTearDown 时机过晚)。 + debugNetworkImageHttpClientProvider = _FakeHttpClient.new; + try { + await pumpThumb(tester, url: 'http://test.local/cover.png'); + + // 立即占位(帧未 ready)。 + expect(find.byIcon(Icons.image_outlined), findsOneWidget); + + // 真实网络字节 + dart:ui 解码在真实事件循环中完成(fake-async 需要 + // runAsync 放行)。 + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); + + expect( + find.byIcon(Icons.image_outlined), + findsNothing, + reason: '加载完成后占位消失', + ); + expect(find.byIcon(Icons.broken_image_outlined), findsNothing); + final image = tester.widget(find.byType(Image)); + expect(image.image, isA()); + } finally { + debugNetworkImageHttpClientProvider = null; + } + }); +} + +/// 以真实 1x1 PNG 字节应答全部 GET 的 HttpClient 替身。 +class _FakeHttpClient implements HttpClient { + @override + bool autoUncompress = true; + + @override + Future getUrl(Uri url) async => _FakeHttpClientRequest(); + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName}'); +} + +class _FakeHttpClientRequest implements HttpClientRequest { + final _FakeHttpHeaders _headers = _FakeHttpHeaders(); + + @override + HttpHeaders get headers => _headers; + + @override + String get method => 'GET'; + + @override + Future close() async => _FakeHttpClientResponse(); + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName}'); +} + +class _FakeHttpClientResponse extends Stream> + implements HttpClientResponse { + late final Stream> _stream = Stream>.value(kPngBytes); + + @override + StreamSubscription> listen( + void Function(List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => _stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + int get statusCode => HttpStatus.ok; + + @override + int get contentLength => kPngBytes.length; + + @override + bool get isRedirect => false; + + @override + bool get persistentConnection => true; + + @override + String get reasonPhrase => 'OK'; + + @override + HttpHeaders get headers => _FakeHttpHeaders(); + + @override + HttpClientResponseCompressionState get compressionState => + HttpClientResponseCompressionState.notCompressed; + + @override + List get cookies => []; + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName}'); +} + +class _FakeHttpHeaders implements HttpHeaders { + final Map _values = {}; + + @override + String? value(String field) => _values[field.toLowerCase()]; + + @override + void add(String name, Object value, {bool preserveHeaderCase = false}) { + _values[name.toLowerCase()] = value.toString(); + } + + @override + void set(String name, Object value, {bool preserveHeaderCase = false}) { + _values[name.toLowerCase()] = value.toString(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName}'); +} diff --git a/apps/mobile/test/ui/section_header_test.dart b/apps/mobile/test/ui/section_header_test.dart new file mode 100644 index 0000000..332ef8c --- /dev/null +++ b/apps/mobile/test/ui/section_header_test.dart @@ -0,0 +1,37 @@ +/// SectionHeader widget 测试(UI-01 Task 3)。 +/// +/// 覆盖:分组标题渲染(titleMedium 字阶)、可选行尾动作渲染/缺省。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/app/theme.dart'; +import 'package:photo_live/ui/components/section_header.dart'; + +void main() { + testWidgets('渲染标题与可选行尾动作', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: const Scaffold( + body: SectionHeader(title: '活动信息', trailing: Text('查看全部')), + ), + ), + ); + + final title = tester.widget(find.text('活动信息')); + expect(title.style!.fontSize, 17, reason: '分组标题字阶 17(titleMedium)'); + expect(find.text('查看全部'), findsOneWidget); + }); + + testWidgets('无行尾动作 → 仅标题', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: SectionHeader(title: '活动信息')), + ), + ); + + expect(find.text('活动信息'), findsOneWidget); + expect(find.byType(TextButton), findsNothing); + }); +} diff --git a/apps/mobile/test/ui/state_badge_test.dart b/apps/mobile/test/ui/state_badge_test.dart new file mode 100644 index 0000000..27cd186 --- /dev/null +++ b/apps/mobile/test/ui/state_badge_test.dart @@ -0,0 +1,84 @@ +/// StateBadge widget 测试(UI-01 Task 3)。 +/// +/// 覆盖:五个语义变体均渲染图标 + 文案 + 语义色(不只靠颜色)、 +/// compact/normal 两档高度、深色主题取提亮变体。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/app/theme.dart'; +import 'package:photo_live/ui/components/state_badge.dart'; +import 'package:photo_live/ui/tokens.dart'; + +void main() { + Future pumpBadge( + WidgetTester tester, { + required BadgeVariant variant, + bool compact = false, + bool dark = false, + }) { + return tester.pumpWidget( + MaterialApp( + theme: dark ? AppTheme.dark : AppTheme.light, + home: Scaffold( + body: Center( + child: StateBadge( + variant: variant, + label: 'badge-label', + compact: compact, + ), + ), + ), + ), + ); + } + + Finder containerOf() => find + .descendant(of: find.byType(StateBadge), matching: find.byType(Container)) + .first; + + for (final entry in <(BadgeVariant, IconData, Color)>[ + (BadgeVariant.success, Icons.check_circle, kColorSuccess), + (BadgeVariant.warning, Icons.hourglass_top, kColorWaiting), + (BadgeVariant.error, Icons.error_outline, kColorError), + (BadgeVariant.neutral, Icons.remove_circle_outline, kColorTextSecondary), + (BadgeVariant.info, Icons.info_outline, kColorInfo), + ]) { + final (variant, icon, color) = entry; + testWidgets('${variant.name}:图标 + 文案 + 语义色', (tester) async { + await pumpBadge(tester, variant: variant); + + expect(find.byIcon(icon), findsOneWidget, reason: '内置语义图标'); + expect(find.text('badge-label'), findsOneWidget, reason: '状态文案'); + expect(find.byType(StateBadge), findsOneWidget); + + final container = tester.widget(containerOf()); + final decoration = container.decoration as BoxDecoration; + expect( + decoration.color, + color.withValues(alpha: 0.12), + reason: '浅色底:语义色 12% 透明度', + ); + + final text = tester.widget(find.text('badge-label')); + expect(text.style!.color, color, reason: '文字用语义色'); + }); + } + + testWidgets('compact 高 24 / normal 高 32', (tester) async { + await pumpBadge(tester, variant: BadgeVariant.success, compact: true); + expect(tester.getSize(find.byType(StateBadge)).height, 24); + + await pumpBadge(tester, variant: BadgeVariant.success); + expect(tester.getSize(find.byType(StateBadge)).height, 32); + }); + + testWidgets('深色主题取提亮变体(success 例)', (tester) async { + await pumpBadge(tester, variant: BadgeVariant.success, dark: true); + + expect(find.byIcon(Icons.check_circle), findsOneWidget); + final text = tester.widget(find.text('badge-label')); + expect(text.style!.color, kColorSuccessDark); + expect(kColorSuccessDark, isNot(kColorSuccess), reason: '深浅变体不同值'); + }); +}