feat(mobile): UI-01 theme system, shared components and navigation

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 14:55:29 +08:00
parent 8f4ad11bd5
commit c94b9e5e58
27 changed files with 2067 additions and 555 deletions
+248 -8
View File
@@ -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,
),
);
}
}
@@ -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<AlbumTranslation> 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;
}
@@ -32,28 +32,26 @@ enum QueueStateFilter {
/// 分组对应的状态集合(空 = 不过滤)。
Set<UploadTaskState> get states => switch (this) {
QueueStateFilter.all => const <UploadTaskState>{},
QueueStateFilter.preparing => const <UploadTaskState>{
UploadTaskState.preparing,
},
QueueStateFilter.queued => const <UploadTaskState>{
UploadTaskState.queued,
},
QueueStateFilter.uploading => const <UploadTaskState>{
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
},
QueueStateFilter.waitingNetwork => const <UploadTaskState>{
UploadTaskState.waitingNetwork,
},
QueueStateFilter.failed => const <UploadTaskState>{
UploadTaskState.preparationFailed,
UploadTaskState.retryableFailed,
},
QueueStateFilter.completed => const <UploadTaskState>{
UploadTaskState.completed,
},
};
QueueStateFilter.all => const <UploadTaskState>{},
QueueStateFilter.preparing => const <UploadTaskState>{
UploadTaskState.preparing,
},
QueueStateFilter.queued => const <UploadTaskState>{UploadTaskState.queued},
QueueStateFilter.uploading => const <UploadTaskState>{
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
},
QueueStateFilter.waitingNetwork => const <UploadTaskState>{
UploadTaskState.waitingNetwork,
},
QueueStateFilter.failed => const <UploadTaskState>{
UploadTaskState.preparationFailed,
UploadTaskState.retryableFailed,
},
QueueStateFilter.completed => const <UploadTaskState>{
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<QueueFilter> {
@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, QueueFilter>(QueueFilterNotifier.new);
final queueFilterProvider = NotifierProvider<QueueFilterNotifier, QueueFilter>(
QueueFilterNotifier.new,
);
/// 防抖重查间隔(连拍/引擎迁移写风暴下限流)。
const Duration kQueueRefreshDebounce = Duration(milliseconds: 200);
@@ -158,13 +156,12 @@ class QueueTasksNotifier extends AsyncNotifier<List<UploadTask>> {
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<List<UploadTask>> {
_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<List<UploadTask>>(tasks));
_query(
ref.read(taskStoreProvider),
account,
ref.read(queueFilterProvider),
).then((tasks) => state = AsyncData<List<UploadTask>>(tasks));
});
}
@@ -188,8 +188,8 @@ class QueueTasksNotifier extends AsyncNotifier<List<UploadTask>> {
/// 队列任务列表 Provider。
final queueTasksProvider =
AsyncNotifierProvider<QueueTasksNotifier, List<UploadTask>>(
QueueTasksNotifier.new,
);
QueueTasksNotifier.new,
);
/// 账号内各状态计数(连拍页徽章/批量重试条;写通知防抖刷新)。
class TaskCountsNotifier extends Notifier<Map<UploadTaskState, int>> {
@@ -234,8 +234,8 @@ class TaskCountsNotifier extends Notifier<Map<UploadTaskState, int>> {
/// 各状态计数 Provider(键覆盖全部状态,无任务计 0)。
final taskCountsProvider =
NotifierProvider<TaskCountsNotifier, Map<UploadTaskState, int>>(
TaskCountsNotifier.new,
);
TaskCountsNotifier.new,
);
/// 未完成(待传)计数:准备中/待传/上传中/确认中/等待网络之和。
int pendingCountOf(Map<UploadTaskState, int> counts) =>
@@ -249,3 +249,15 @@ int pendingCountOf(Map<UploadTaskState, int> counts) =>
int failedCountOf(Map<UploadTaskState, int> counts) =>
counts[UploadTaskState.preparationFailed]! +
counts[UploadTaskState.retryableFailed]!;
/// 「未结束」计数(底部导航「上传」角标,UI-01 §4.1):待传(
/// [pendingCountOf])+ 可重试失败——均为引擎待推进或用户需关注的活任务;
/// 准备失败/取消/完成不计入。账号隔离由 TaskStore.countsByState 硬守护。
int unfinishedCountOf(Map<UploadTaskState, int> counts) =>
pendingCountOf(counts) + counts[UploadTaskState.retryableFailed]!;
/// 导航角标计数 Provider:主壳 watch(计数不变不触发重建;页签状态由
/// IndexedStack 保持,不随角标重建)。
final Provider<int> uploadBadgeCountProvider = Provider<int>(
(ref) => unfinishedCountOf(ref.watch(taskCountsProvider)),
);
@@ -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;
}
+8 -3
View File
@@ -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",
+31 -7
View File
@@ -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.
///
+17 -3
View File
@@ -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';
+17 -3
View File
@@ -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 => '使用工作人员账号登录';
+8 -3
View File
@@ -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": "修改密码",
+77 -193
View File
@@ -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<void> 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<void>(
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,
),
),
);
}
}
+40 -14
View File
@@ -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<HomePage> createState() => _HomePageState();
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
class _HomePageState extends ConsumerState<HomePage> {
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<HomePage> {
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<HomePage> {
}
}
/// 上传页签图标 + 未完成任务角标(≤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});
+78 -78
View File
@@ -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<String?>(
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,
@@ -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!),
),
],
],
),
),
);
}
}
@@ -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),
),
],
),
),
);
}
}
@@ -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),
),
);
}
}
@@ -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!],
],
);
}
}
@@ -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),
};
}
+83
View File
@@ -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;
}
+174
View File
@@ -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 <WidgetState>{}),
const Size(64, 48),
);
expect(
theme.elevatedButtonTheme.style!.minimumSize!.resolve(
const <WidgetState>{},
),
const Size(64, 48),
);
final shape =
filled.shape!.resolve(const <WidgetState>{})
as RoundedRectangleBorder;
expect(shape.borderRadius, BorderRadius.circular(kRadiusInput));
expect(
theme.outlinedButtonTheme.style!.minimumSize!.resolve(
const <WidgetState>{},
),
const Size(48, 48),
);
expect(
theme.textButtonTheme.style!.minimumSize!.resolve(
const <WidgetState>{},
),
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);
});
});
}
+93 -99
View File
@@ -145,17 +145,14 @@ class FakeAdapter implements HttpClientAdapter {
Map<String, dynamic> loginOkBody({
String token = 'tok-new',
String expiresAt = '2026-09-20T12:00:00Z',
}) => <String, dynamic>{
'sessionToken': token,
'expiresAt': expiresAt,
};
}) => <String, dynamic>{'sessionToken': token, 'expiresAt': expiresAt};
/// 常用响应体:契约 Error 形状。
Map<String, dynamic> errorBody(String code) => <String, dynamic>{
'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<String, dynamic> albumJson({
bool originalDownloadEnabled = false,
List<Map<String, dynamic>>? translations,
List<Map<String, dynamic>> categories = const <Map<String, dynamic>>[],
}) =>
<String, dynamic>{
'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 ??
<Map<String, dynamic>>[
<String, dynamic>{
'locale': 'zh-CN',
'title': '开幕晚宴',
'description': null,
'venue': '大宴会厅',
'address': null,
},
<String, dynamic>{
'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<String, dynamic>? cover,
}) => <String, dynamic>{
'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 ??
<Map<String, dynamic>>[
<String, dynamic>{
'locale': 'zh-CN',
'title': '开幕晚宴',
'description': null,
'venue': '大宴会厅',
'address': null,
},
<String, dynamic>{
'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<String, dynamic> categoryJson({
@@ -220,17 +218,17 @@ Map<String, dynamic> categoryJson({
String albumId = 'alb-1',
int sortOrder = 0,
List<Map<String, dynamic>>? translations,
}) =>
<String, dynamic>{
'id': id,
'albumId': albumId,
'sortOrder': sortOrder,
'translations': translations ??
<Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '仪式'},
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
],
};
}) => <String, dynamic>{
'id': id,
'albumId': albumId,
'sortOrder': sortOrder,
'translations':
translations ??
<Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '仪式'},
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
],
};
/// 契约 `Member` 响应体。
Map<String, dynamic> memberJson({
@@ -238,26 +236,24 @@ Map<String, dynamic> memberJson({
String account = 'staff02',
String displayName = 'Staff Two',
String role = 'collaborator',
}) =>
<String, dynamic>{
'userId': userId,
'account': account,
'displayName': displayName,
'role': role,
'status': 'active',
};
}) => <String, dynamic>{
'userId': userId,
'account': account,
'displayName': displayName,
'role': role,
'status': 'active',
};
/// 契约 `AdminUserLookupItem` 响应体。
Map<String, dynamic> lookupUserJson({
String id = 'u-9',
String account = 'staff09',
String displayName = 'Staff Nine',
}) =>
<String, dynamic>{
'id': id,
'account': account,
'displayName': displayName,
};
}) => <String, dynamic>{
'id': id,
'account': account,
'displayName': displayName,
};
/// 契约 `AdminPhoto` 响应体。
Map<String, dynamic> adminPhotoJson({
@@ -266,36 +262,34 @@ Map<String, dynamic> adminPhotoJson({
String visibility = 'published',
String processingState = 'ready',
int bytes = 123456,
}) =>
<String, dynamic>{
'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': <String, dynamic>{
'userId': 'u-2',
'account': 'staff02',
'displayName': 'Staff Two',
},
'preview': null,
};
}) => <String, dynamic>{
'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': <String, dynamic>{
'userId': 'u-2',
'account': 'staff02',
'displayName': 'Staff Two',
},
'preview': null,
};
/// 契约游标分页响应体(hasMore 与 nextCursor 成对)。
Map<String, dynamic> cursorPageBody(
List<Map<String, dynamic>> items, {
String? nextCursor,
}) =>
<String, dynamic>{
'items': items,
'nextCursor': nextCursor,
'hasMore': nextCursor != null,
};
}) => <String, dynamic>{
'items': items,
'nextCursor': nextCursor,
'hasMore': nextCursor != null,
};
+76 -48
View File
@@ -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<void> 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<Map<String, dynamic>>.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);
});
}
+194
View File
@@ -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<void> _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<void> pumpHome(WidgetTester tester) async {
// 相册列表页签进入即拉列表;空页兜底即可。
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (200, cursorPageBody(<Map<String, dynamic>>[]))
: 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<void> 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 = <String>[
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<UploadTaskState, int> counts(Map<UploadTaskState, int> patch) =>
<UploadTaskState, int>{
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,
);
});
}
+60
View File
@@ -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<void> 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);
});
}
+61
View File
@@ -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<void> 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<Icon>(find.byIcon(Icons.cloud_off));
expect(icon.color, kColorErrorDark);
});
}
void _noop() {}
@@ -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<void> 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<void>.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<Image>(find.byType(Image));
expect(image.image, isA<NetworkImage>());
} finally {
debugNetworkImageHttpClientProvider = null;
}
});
}
/// 以真实 1x1 PNG 字节应答全部 GET 的 HttpClient 替身。
class _FakeHttpClient implements HttpClient {
@override
bool autoUncompress = true;
@override
Future<HttpClientRequest> 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<HttpClientResponse> close() async => _FakeHttpClientResponse();
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError('${invocation.memberName}');
}
class _FakeHttpClientResponse extends Stream<List<int>>
implements HttpClientResponse {
late final Stream<List<int>> _stream = Stream<List<int>>.value(kPngBytes);
@override
StreamSubscription<List<int>> listen(
void Function(List<int> 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<Cookie> get cookies => <Cookie>[];
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError('${invocation.memberName}');
}
class _FakeHttpHeaders implements HttpHeaders {
final Map<String, String> _values = <String, String>{};
@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}');
}
@@ -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<Text>(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);
});
}
+84
View File
@@ -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<void> 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<Container>(containerOf());
final decoration = container.decoration as BoxDecoration;
expect(
decoration.color,
color.withValues(alpha: 0.12),
reason: '浅色底:语义色 12% 透明度',
);
final text = tester.widget<Text>(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<Text>(find.text('badge-label'));
expect(text.style!.color, kColorSuccessDark);
expect(kColorSuccessDark, isNot(kColorSuccess), reason: '深浅变体不同值');
});
}