feat(mobile): UI-01 theme system, shared components and navigation
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 => '使用工作人员账号登录';
|
||||
|
||||
@@ -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": "修改密码",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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: '深浅变体不同值');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user