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

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 14:55:29 +08:00
parent 8f4ad11bd5
commit c94b9e5e58
27 changed files with 2067 additions and 555 deletions
+248 -8
View File
@@ -1,17 +1,257 @@
/// UI-01 主题(视觉规范 §3.1 精确落地)。
///
/// 浅色基准:primary #1A73E8 / surface #FFFFFF / 页面背景 #F6F8FB /
/// 主文字 #172033 / 次文字 #5E6878 / 分隔线 #E5EAF1;等待色 #996300 承载
/// ColorScheme.secondary、错误 #B42318 承载 error。成功色**不进**
/// ColorScheme(Material 无该语义角色),以独立令牌提供(见 ui/tokens.dart,
/// 成功/等待/错误均另有深色提亮变体)。
///
/// 深色:语义对应色(深表面 + 浅文字 + 提亮的主色/等待色/错误色);图片
/// 与内容不做反色处理,沿用跟随系统的深浅色切换。容器色(secondary/
/// primaryContainer 等)由种子色派生,与 #1A73E8 同系。
///
/// 字阶(fontSize 走 TextScaler,随系统字体缩放):22 页面标题 /
/// 17 分组标题 / 16-14 正文 / 13-12 辅助。组件:主按钮高 48(区间
/// 48-52)、输入框与按钮圆角 12、卡片圆角 16、Dialog 圆角 16、
/// BottomSheet 顶部圆角 16。动效时长常量见 ui/tokens.dart。
library;
import 'package:flutter/material.dart';
/// 基础主题(Material 3,种子色)。完整视觉规范随后续任务迭代。
import '../ui/tokens.dart';
abstract final class AppTheme {
/// Material 种子色(容器色派生基准)。
static const Color seedColor = Color(0xFF1A73E8);
static ThemeData get light => ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: seedColor),
);
// ── 浅色基准值(方案 §3.1)──────────────────────────────────────────
static ThemeData get dark => ThemeData(
colorScheme: ColorScheme.fromSeed(
static const Color _primary = Color(0xFF1A73E8);
static const Color _onPrimary = Color(0xFFFFFFFF);
static const Color _surface = Color(0xFFFFFFFF);
static const Color _onSurface = Color(0xFF172033);
static const Color _background = Color(0xFFF6F8FB);
static const Color _textSecondary = Color(0xFF5E6878);
static const Color _divider = Color(0xFFE5EAF1);
static const Color _waiting = Color(0xFF996300);
static const Color _onWaiting = Color(0xFFFFFFFF);
static const Color _error = Color(0xFFB42318);
// ── 深色对应语义值(同语义、深色表面提亮)──────────────────────────
static const Color _primaryDark = Color(0xFF8AB4F8);
static const Color _onPrimaryDark = Color(0xFF0B2E6F);
static const Color _surfaceDark = Color(0xFF1F2530);
static const Color _onSurfaceDark = Color(0xFFE6EAF2);
static const Color _backgroundDark = Color(0xFF12161D);
static const Color _textSecondaryDark = Color(0xFFA6B0C0);
static const Color _dividerDark = Color(0xFF333B47);
static const Color _waitingDark = Color(0xFFFCD34D);
static const Color _onWaitingDark = Color(0xFF402E00);
static const Color _errorDark = Color(0xFFF87171);
static const Color _onErrorDark = Color(0xFF5C1A15);
static ThemeData get light => _build(Brightness.light);
static ThemeData get dark => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) {
final dark = brightness == Brightness.dark;
final colorScheme =
ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
brightness: brightness,
).copyWith(
primary: dark ? _primaryDark : _primary,
onPrimary: dark ? _onPrimaryDark : _onPrimary,
secondary: dark ? _waitingDark : _waiting,
onSecondary: dark ? _onWaitingDark : _onWaiting,
error: dark ? _errorDark : _error,
onError: dark ? _onErrorDark : Colors.white,
surface: dark ? _surfaceDark : _surface,
onSurface: dark ? _onSurfaceDark : _onSurface,
surfaceContainerHighest: dark ? _dividerDark : _divider,
onSurfaceVariant: dark ? _textSecondaryDark : _textSecondary,
);
final textTheme = _textTheme(colorScheme);
final buttonBorder = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadiusInput),
);
return ThemeData(
brightness: brightness,
colorScheme: colorScheme,
scaffoldBackgroundColor: dark ? _backgroundDark : _background,
textTheme: textTheme,
// AppBar:干净(零海拔 + 页面背景同色),标题用 titleLarge。
appBarTheme: AppBarTheme(
backgroundColor: dark ? _backgroundDark : _background,
surfaceTintColor: Colors.transparent,
foregroundColor: colorScheme.onSurface,
titleTextStyle: textTheme.titleLarge,
elevation: 0,
scrolledUnderElevation: 1,
centerTitle: false,
),
// 按钮:主/次操作统一高 48(规格 48-52)、圆角 12。
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(64, 48),
shape: buttonBorder,
),
);
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(64, 48),
shape: buttonBorder,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(48, 48),
shape: buttonBorder,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: const Size(48, 48),
shape: buttonBorder,
),
),
// 输入框:圆角 12,轻分隔线边框,聚焦主色 2px。
inputDecorationTheme: InputDecorationTheme(
border: _inputBorder(colorScheme, colorScheme.outline),
enabledBorder: _inputBorder(
colorScheme,
dark ? _dividerDark : _divider,
),
focusedBorder: _inputBorder(colorScheme, colorScheme.primary, width: 2),
errorBorder: _inputBorder(colorScheme, colorScheme.error),
focusedErrorBorder: _inputBorder(
colorScheme,
colorScheme.error,
width: 2,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: kSpace16,
vertical: kSpace12,
),
),
// 卡片:白色表面(深色深表面)、圆角 16、轻阴影。
cardTheme: CardThemeData(
color: colorScheme.surface,
surfaceTintColor: Colors.transparent,
elevation: 1,
shadowColor: Colors.black.withValues(alpha: dark ? 0.4 : 0.08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadiusCard),
),
),
// Dialog 圆角 16;BottomSheet 仅顶部圆角 16。
dialogTheme: DialogThemeData(
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadiusCard),
),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(kRadiusCard),
),
),
),
// Snackbar:浮动 + inverseSurface(自动随深浅色),动作色 inversePrimary。
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: colorScheme.inverseSurface,
contentTextStyle: textTheme.bodyMedium?.copyWith(
color: colorScheme.onInverseSurface,
),
actionTextColor: colorScheme.inversePrimary,
),
// 底部导航:白色表面(深色深表面)+ 派生容器色指示器。
navigationBarTheme: NavigationBarThemeData(
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
indicatorColor: colorScheme.secondaryContainer,
height: 80,
labelTextStyle: WidgetStatePropertyAll(
textTheme.labelMedium!.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
// 轻分隔线(不用粗边框)。
dividerTheme: DividerThemeData(
color: dark ? _dividerDark : _divider,
thickness: 1,
space: 1,
),
);
}
static OutlineInputBorder _inputBorder(
ColorScheme scheme,
Color color, {
double width = 1,
}) => OutlineInputBorder(
borderRadius: BorderRadius.circular(kRadiusInput),
borderSide: BorderSide(color: color, width: width),
);
/// 字阶(方案 §3.1):22 页面标题 / 17 分组 / 16-14 正文 / 13-12 辅助。
///
/// 只声明 fontSize/字重/颜色,字体族与缩放交给默认排版 + TextScaler
/// (fontSize 随系统字体大小缩放)。
static TextTheme _textTheme(ColorScheme scheme) {
final onSurface = scheme.onSurface;
final onSecondaryText = scheme.onSurfaceVariant;
return TextTheme(
titleLarge: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: onSurface,
),
titleMedium: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: onSurface,
),
titleSmall: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: onSurface,
),
bodyLarge: TextStyle(fontSize: 16, color: onSurface),
bodyMedium: TextStyle(fontSize: 14, color: onSurface),
bodySmall: TextStyle(fontSize: 13, color: onSecondaryText),
labelLarge: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: onSurface,
),
labelMedium: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: onSurface,
),
labelSmall: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: onSecondaryText,
),
);
}
}
@@ -1,4 +1,4 @@
/// 相册时区感知的时间展示(P3 计划 Task 4)。
/// 相册时区感知的时间展示与多语言展示回退(P3 计划 Task 4;UI-01 §4.1)。
///
/// 契约:startAt/endAt 按 UTC 存储,「展示按 timezone」。Dart 核心库无
/// IANA 时区数据库,引入 `timezone` 包(内嵌 tzdata)做墙钟换算,字符串
@@ -10,6 +10,8 @@ import 'package:intl/intl.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:timezone/timezone.dart' as tz;
import 'models.dart';
bool _tzInitialized = false;
/// 初始化内嵌 tzdata(幂等;首次调用后为空操作)。
@@ -73,14 +75,56 @@ DateTime albumWallClockToUtc(DateTime wallClock, String timeZone) {
wallClock.minute,
);
}
return tz
.TZDateTime(
location,
wallClock.year,
wallClock.month,
wallClock.day,
wallClock.hour,
wallClock.minute,
)
.toUtc();
return tz.TZDateTime(
location,
wallClock.year,
wallClock.month,
wallClock.day,
wallClock.hour,
wallClock.minute,
).toUtc();
}
// ── 多语言展示回退链(UI-01 §4.1)────────────────────────────────────────
//
// 展示顺序:界面语言(语言前缀匹配,App locale `zh`/`en` vs 契约
// `zh-CN`/`en`)→ 相册默认语言 → 首个条目 → 相册 ID 兜底。
/// 展示回退链:界面语言条目优先,其余按「默认语言在前」排列。
List<AlbumTranslation> albumDisplayTranslations(
AlbumSummary album,
String localeName,
) {
final lang = localeName.split('-').first.toLowerCase();
AlbumTranslation? langMatch;
for (final t in album.translations) {
final l = t.locale.toLowerCase();
if (l == localeName.toLowerCase() || l.split('-').first == lang) {
langMatch = t;
break;
}
}
int byDefault(AlbumTranslation a, AlbumTranslation b) =>
(a.locale == album.defaultLocale ? 0 : 1).compareTo(
b.locale == album.defaultLocale ? 0 : 1,
);
if (langMatch != null) {
final rest = album.translations.where((t) => t != langMatch).toList()
..sort(byDefault);
return [langMatch, ...rest];
}
return [...album.translations]..sort(byDefault);
}
/// 展示标题(UI-01 §4.1 回退链;无翻译时相册 ID 兜底)。
String albumDisplayTitle(AlbumSummary album, String localeName) =>
albumDisplayTranslations(album, localeName).firstOrNull?.title ?? album.id;
/// 展示场地(同一回退链上首个非空场地;全空返回 null,不占一整行空白)。
String? albumDisplayVenue(AlbumSummary album, String localeName) {
for (final t in albumDisplayTranslations(album, localeName)) {
final venue = t.venue?.trim();
if (venue != null && venue.isNotEmpty) return venue;
}
return null;
}
@@ -32,28 +32,26 @@ enum QueueStateFilter {
/// 分组对应的状态集合(空 = 不过滤)。
Set<UploadTaskState> get states => switch (this) {
QueueStateFilter.all => const <UploadTaskState>{},
QueueStateFilter.preparing => const <UploadTaskState>{
UploadTaskState.preparing,
},
QueueStateFilter.queued => const <UploadTaskState>{
UploadTaskState.queued,
},
QueueStateFilter.uploading => const <UploadTaskState>{
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
},
QueueStateFilter.waitingNetwork => const <UploadTaskState>{
UploadTaskState.waitingNetwork,
},
QueueStateFilter.failed => const <UploadTaskState>{
UploadTaskState.preparationFailed,
UploadTaskState.retryableFailed,
},
QueueStateFilter.completed => const <UploadTaskState>{
UploadTaskState.completed,
},
};
QueueStateFilter.all => const <UploadTaskState>{},
QueueStateFilter.preparing => const <UploadTaskState>{
UploadTaskState.preparing,
},
QueueStateFilter.queued => const <UploadTaskState>{UploadTaskState.queued},
QueueStateFilter.uploading => const <UploadTaskState>{
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
},
QueueStateFilter.waitingNetwork => const <UploadTaskState>{
UploadTaskState.waitingNetwork,
},
QueueStateFilter.failed => const <UploadTaskState>{
UploadTaskState.preparationFailed,
UploadTaskState.retryableFailed,
},
QueueStateFilter.completed => const <UploadTaskState>{
UploadTaskState.completed,
},
};
}
/// 时间筛选快捷(今天/近7天/近30天/全部)。
@@ -66,13 +64,11 @@ enum QueueTimeFilter {
/// 创建时间下限(null = 不过滤;本地时刻,matches 内与 UTC 落库值
/// 比较前归一)。
DateTime? since(DateTime now) => switch (this) {
QueueTimeFilter.all => null,
QueueTimeFilter.today => DateTime(now.year, now.month, now.day),
QueueTimeFilter.last7Days =>
now.subtract(const Duration(days: 7)),
QueueTimeFilter.last30Days =>
now.subtract(const Duration(days: 30)),
};
QueueTimeFilter.all => null,
QueueTimeFilter.today => DateTime(now.year, now.month, now.day),
QueueTimeFilter.last7Days => now.subtract(const Duration(days: 7)),
QueueTimeFilter.last30Days => now.subtract(const Duration(days: 30)),
};
}
/// 三维筛选条件(不可变值对象;纯匹配逻辑单测覆盖)。
@@ -108,12 +104,11 @@ class QueueFilter {
bool clearAlbum = false,
QueueStateFilter? state,
QueueTimeFilter? time,
}) =>
QueueFilter(
albumId: clearAlbum ? null : (albumId ?? this.albumId),
state: state ?? this.state,
time: time ?? this.time,
);
}) => QueueFilter(
albumId: clearAlbum ? null : (albumId ?? this.albumId),
state: state ?? this.state,
time: time ?? this.time,
);
}
/// 筛选状态 Notifier。
@@ -121,17 +116,20 @@ class QueueFilterNotifier extends Notifier<QueueFilter> {
@override
QueueFilter build() => const QueueFilter();
void setAlbum(String? albumId) =>
state = albumId == null ? state.copyWith(clearAlbum: true) : state.copyWith(albumId: albumId);
void setAlbum(String? albumId) => state = albumId == null
? state.copyWith(clearAlbum: true)
: state.copyWith(albumId: albumId);
void setStateGroup(QueueStateFilter group) => state = state.copyWith(state: group);
void setStateGroup(QueueStateFilter group) =>
state = state.copyWith(state: group);
void setTime(QueueTimeFilter time) => state = state.copyWith(time: time);
}
/// 筛选状态 Provider。
final queueFilterProvider =
NotifierProvider<QueueFilterNotifier, QueueFilter>(QueueFilterNotifier.new);
final queueFilterProvider = NotifierProvider<QueueFilterNotifier, QueueFilter>(
QueueFilterNotifier.new,
);
/// 防抖重查间隔(连拍/引擎迁移写风暴下限流)。
const Duration kQueueRefreshDebounce = Duration(milliseconds: 200);
@@ -158,13 +156,12 @@ class QueueTasksNotifier extends AsyncNotifier<List<UploadTask>> {
TaskStore store,
String account,
QueueFilter filter,
) =>
store.listByAccount(
account,
albumId: filter.albumId,
states: filter.state.states.isEmpty ? null : filter.state.states,
since: filter.time.since(DateTime.now()),
);
) => store.listByAccount(
account,
albumId: filter.albumId,
states: filter.state.states.isEmpty ? null : filter.state.states,
since: filter.time.since(DateTime.now()),
);
/// 写通知 → 200ms 防抖后重查(同一账号/筛选的最新快照)。
void _scheduleReload() {
@@ -172,8 +169,11 @@ class QueueTasksNotifier extends AsyncNotifier<List<UploadTask>> {
_debounce = Timer(kQueueRefreshDebounce, () {
final account = ref.read(sessionAccountProvider).value;
if (account == null) return;
_query(ref.read(taskStoreProvider), account, ref.read(queueFilterProvider))
.then((tasks) => state = AsyncData<List<UploadTask>>(tasks));
_query(
ref.read(taskStoreProvider),
account,
ref.read(queueFilterProvider),
).then((tasks) => state = AsyncData<List<UploadTask>>(tasks));
});
}
@@ -188,8 +188,8 @@ class QueueTasksNotifier extends AsyncNotifier<List<UploadTask>> {
/// 队列任务列表 Provider。
final queueTasksProvider =
AsyncNotifierProvider<QueueTasksNotifier, List<UploadTask>>(
QueueTasksNotifier.new,
);
QueueTasksNotifier.new,
);
/// 账号内各状态计数(连拍页徽章/批量重试条;写通知防抖刷新)。
class TaskCountsNotifier extends Notifier<Map<UploadTaskState, int>> {
@@ -234,8 +234,8 @@ class TaskCountsNotifier extends Notifier<Map<UploadTaskState, int>> {
/// 各状态计数 Provider(键覆盖全部状态,无任务计 0)。
final taskCountsProvider =
NotifierProvider<TaskCountsNotifier, Map<UploadTaskState, int>>(
TaskCountsNotifier.new,
);
TaskCountsNotifier.new,
);
/// 未完成(待传)计数:准备中/待传/上传中/确认中/等待网络之和。
int pendingCountOf(Map<UploadTaskState, int> counts) =>
@@ -249,3 +249,15 @@ int pendingCountOf(Map<UploadTaskState, int> counts) =>
int failedCountOf(Map<UploadTaskState, int> counts) =>
counts[UploadTaskState.preparationFailed]! +
counts[UploadTaskState.retryableFailed]!;
/// 「未结束」计数(底部导航「上传」角标,UI-01 §4.1):待传(
/// [pendingCountOf])+ 可重试失败——均为引擎待推进或用户需关注的活任务;
/// 准备失败/取消/完成不计入。账号隔离由 TaskStore.countsByState 硬守护。
int unfinishedCountOf(Map<UploadTaskState, int> counts) =>
pendingCountOf(counts) + counts[UploadTaskState.retryableFailed]!;
/// 导航角标计数 Provider:主壳 watch(计数不变不触发重建;页签状态由
/// IndexedStack 保持,不随角标重建)。
final Provider<int> uploadBadgeCountProvider = Provider<int>(
(ref) => unfinishedCountOf(ref.watch(taskCountsProvider)),
);
@@ -8,7 +8,6 @@ library;
import '../../core/error_codes.dart';
import '../../core/error_l10n.dart';
import '../../l10n/app_localizations.dart';
import '../albums/models.dart';
import 'preparer.dart';
import 'task_state.dart';
import 'upload_engine.dart';
@@ -16,16 +15,16 @@ import 'upload_engine.dart';
extension AppLocalizationsUploadX on AppLocalizations {
/// 任务状态标签(chip/文案)。
String labelForTaskState(UploadTaskState state) => switch (state) {
UploadTaskState.preparing => taskStatePreparing,
UploadTaskState.preparationFailed => taskStatePreparationFailed,
UploadTaskState.queued => taskStateQueued,
UploadTaskState.uploading => taskStateUploading,
UploadTaskState.uploadedConfirming => taskStateUploadedConfirming,
UploadTaskState.completed => taskStateCompleted,
UploadTaskState.waitingNetwork => taskStateWaitingNetwork,
UploadTaskState.retryableFailed => taskStateRetryableFailed,
UploadTaskState.cancelled => taskStateCancelled,
};
UploadTaskState.preparing => taskStatePreparing,
UploadTaskState.preparationFailed => taskStatePreparationFailed,
UploadTaskState.queued => taskStateQueued,
UploadTaskState.uploading => taskStateUploading,
UploadTaskState.uploadedConfirming => taskStateUploadedConfirming,
UploadTaskState.completed => taskStateCompleted,
UploadTaskState.waitingNetwork => taskStateWaitingNetwork,
UploadTaskState.retryableFailed => taskStateRetryableFailed,
UploadTaskState.cancelled => taskStateCancelled,
};
/// 任务 lastError → 展示文案(上传域伪码 + 契约码;未映射回退
/// error_l10n 的通用解析)。
@@ -52,24 +51,3 @@ extension AppLocalizationsUploadX on AppLocalizations {
}
}
}
/// 相册展示标题解析:当前 locale → 相册默认语言 → 首条 → 相册 ID。
///
/// locale 匹配按语言前缀(App locale `zh`/`en` vs 契约 `zh-CN`/`en`)。
String albumDisplayTitle(AlbumSummary album, String localeName) {
final lang = localeName.split('-').first.toLowerCase();
AlbumTranslation? match;
for (final t in album.translations) {
if (t.locale.toLowerCase() == localeName.toLowerCase() ||
t.locale.toLowerCase().split('-').first == lang) {
match = t;
break;
}
}
if (match != null) return match.title;
for (final t in album.translations) {
if (t.locale == album.defaultLocale) return t.title;
}
if (album.translations.isNotEmpty) return album.translations.first.title;
return album.id;
}
+8 -3
View File
@@ -17,9 +17,14 @@
"cancel": "Cancel",
"errorGeneric": "Something went wrong, please retry",
"ok": "OK",
"homeTabAlbums": "Albums",
"homeTabQueue": "Queue",
"homeTabSettings": "Settings",
"navAlbums": "Albums",
"navUploads": "Uploads",
"navSettings": "Settings",
"badgeUploading": "{count} tasks",
"@badgeUploading": { "placeholders": { "count": { "type": "int" } } },
"albumUploadsOn": "Uploads on",
"albumUploadsOff": "Uploads off",
"albumPublicOff": "Not public",
"loginHint": "Sign in with your staff account",
"fieldRequired": "This field is required",
"passwordChangeTitle": "Change password",
+31 -7
View File
@@ -200,23 +200,47 @@ abstract class AppLocalizations {
/// **'好的'**
String get ok;
/// No description provided for @homeTabAlbums.
/// No description provided for @navAlbums.
///
/// In zh, this message translates to:
/// **'相册'**
String get homeTabAlbums;
String get navAlbums;
/// No description provided for @homeTabQueue.
/// No description provided for @navUploads.
///
/// In zh, this message translates to:
/// **'队列'**
String get homeTabQueue;
/// **'上传'**
String get navUploads;
/// No description provided for @homeTabSettings.
/// No description provided for @navSettings.
///
/// In zh, this message translates to:
/// **'设置'**
String get homeTabSettings;
String get navSettings;
/// No description provided for @badgeUploading.
///
/// In zh, this message translates to:
/// **'{count} 个任务'**
String badgeUploading(int count);
/// No description provided for @albumUploadsOn.
///
/// In zh, this message translates to:
/// **'开放上传'**
String get albumUploadsOn;
/// No description provided for @albumUploadsOff.
///
/// In zh, this message translates to:
/// **'上传已关闭'**
String get albumUploadsOff;
/// No description provided for @albumPublicOff.
///
/// In zh, this message translates to:
/// **'未公开'**
String get albumPublicOff;
/// No description provided for @loginHint.
///
+17 -3
View File
@@ -60,13 +60,27 @@ class AppLocalizationsEn extends AppLocalizations {
String get ok => 'OK';
@override
String get homeTabAlbums => 'Albums';
String get navAlbums => 'Albums';
@override
String get homeTabQueue => 'Queue';
String get navUploads => 'Uploads';
@override
String get homeTabSettings => 'Settings';
String get navSettings => 'Settings';
@override
String badgeUploading(int count) {
return '$count tasks';
}
@override
String get albumUploadsOn => 'Uploads on';
@override
String get albumUploadsOff => 'Uploads off';
@override
String get albumPublicOff => 'Not public';
@override
String get loginHint => 'Sign in with your staff account';
+17 -3
View File
@@ -60,13 +60,27 @@ class AppLocalizationsZh extends AppLocalizations {
String get ok => '好的';
@override
String get homeTabAlbums => '相册';
String get navAlbums => '相册';
@override
String get homeTabQueue => '队列';
String get navUploads => '上传';
@override
String get homeTabSettings => '设置';
String get navSettings => '设置';
@override
String badgeUploading(int count) {
return '$count 个任务';
}
@override
String get albumUploadsOn => '开放上传';
@override
String get albumUploadsOff => '上传已关闭';
@override
String get albumPublicOff => '未公开';
@override
String get loginHint => '使用工作人员账号登录';
+8 -3
View File
@@ -17,9 +17,14 @@
"cancel": "取消",
"errorGeneric": "出错了,请重试",
"ok": "好的",
"homeTabAlbums": "相册",
"homeTabQueue": "队列",
"homeTabSettings": "设置",
"navAlbums": "相册",
"navUploads": "上传",
"navSettings": "设置",
"badgeUploading": "{count} 个任务",
"@badgeUploading": { "placeholders": { "count": { "type": "int" } } },
"albumUploadsOn": "开放上传",
"albumUploadsOff": "上传已关闭",
"albumPublicOff": "未公开",
"loginHint": "使用工作人员账号登录",
"fieldRequired": "此项为必填项",
"passwordChangeTitle": "修改密码",
+77 -193
View File
@@ -1,6 +1,8 @@
/// 相册列表页签(P3 计划 Task 4):游标加载更多、下拉刷新、卡片标签行
/// (状态 + 四开关)、时区感知的时间展示、公开令牌短显、FAB 新建;
/// 加载/空/错误态齐备。详情/编辑由卡片与列表级刷新联动。
/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1 样板)。
///
/// 卡片顺序:封面(16:9,无封面中性占位)→ 标题(≤2 行,语言回退链)→
/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。完整列表
/// 交互(平板两列、错误重试等)随后续任务迭代。
library;
import 'package:flutter/material.dart';
@@ -11,6 +13,11 @@ import '../features/albums/album_format.dart';
import '../features/albums/models.dart';
import '../features/albums/providers.dart';
import '../l10n/app_localizations.dart';
import '../ui/components/empty_state.dart';
import '../ui/components/error_state.dart';
import '../ui/components/photo_thumbnail.dart';
import '../ui/components/state_badge.dart';
import '../ui/tokens.dart';
import 'album_detail_page.dart';
import 'album_form_page.dart';
@@ -59,12 +66,15 @@ class AlbumListBody extends ConsumerWidget {
}
if (state.items.isEmpty) {
if (state.errorCode != null) {
return _ErrorView(
return ErrorState(
message: l10n.messageForErrorCode(state.errorCode!),
onRetry: notifier.refresh,
);
}
return _EmptyView(message: l10n.albumsEmpty);
return EmptyState(
icon: Icons.photo_library_outlined,
title: l10n.albumsEmpty,
);
}
final hasFooter = state.hasMore || state.loadMoreFailed;
@@ -77,14 +87,16 @@ class AlbumListBody extends ConsumerWidget {
if (index >= state.items.length) {
if (state.loadMoreFailed) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
contentPadding: const EdgeInsets.symmetric(
horizontal: kSpace16,
),
leading: const Icon(Icons.error_outline),
title: Text(l10n.loadMoreFailed),
onTap: notifier.loadMore,
);
}
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
padding: EdgeInsets.symmetric(vertical: kSpace16),
child: Center(
child: SizedBox(
height: 24,
@@ -109,58 +121,11 @@ class AlbumListBody extends ConsumerWidget {
}
}
/// 首屏错误视图(重试按钮触发整页刷新)。
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message, required this.onRetry});
final String message;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(l10n.retry),
),
],
),
);
}
}
/// 空列表视图。
class _EmptyView extends StatelessWidget {
const _EmptyView({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.photo_library_outlined, size: 48),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
],
),
);
}
}
/// 相册卡片:默认语言标题、状态 + 四开关标签行、相册时区的时间区间、
/// 公开令牌短显。
/// 相册卡片(UI-01 §4.1 样板):封面 → 标题 → 时间/场地 → 状态徽章。
///
/// 标题按「界面语言 → 相册默认语言 → 首个条目」回退;时间用相册时区;
/// 公开/上传分别显示(关闭不等于不公开)。publicToken 与四开关堆叠已从
/// 主卡片移除(完整配置在详情设置)。
class AlbumCard extends StatelessWidget {
const AlbumCard({required this.album, super.key});
@@ -170,98 +135,78 @@ class AlbumCard extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final time = _timeRange(l10n);
final venue = albumDisplayVenue(album, l10n.localeName);
final meta = venue == null
? _timeRange(l10n)
: '${_timeRange(l10n)} · $venue';
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
margin: const EdgeInsets.symmetric(
horizontal: kSpace16,
vertical: kSpace8,
),
clipBehavior: Clip.antiAlias,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumDetailPage(albumId: album.id),
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9),
Padding(
padding: const EdgeInsets.all(kSpace16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
Text(
albumDisplayTitle(album, l10n.localeName),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
),
const SizedBox(width: 8),
_StatusChip(status: album.status),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_SwitchChip(label: l10n.tagUpload, on: album.uploadEnabled),
_SwitchChip(label: l10n.tagPublic, on: album.publicEnabled),
_SwitchChip(
label: l10n.tagDownload,
on: album.downloadEnabled,
const SizedBox(height: kSpace4),
Text(
meta,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
_SwitchChip(
label: l10n.tagOriginal,
on: album.originalDownloadEnabled,
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.schedule,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
time,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
const SizedBox(height: kSpace12),
Row(
children: [
StateBadge(
variant: album.publicEnabled
? BadgeVariant.info
: BadgeVariant.neutral,
label: album.publicEnabled
? l10n.tagPublic
: l10n.albumPublicOff,
compact: true,
),
),
const SizedBox(width: kSpace8),
StateBadge(
variant: album.uploadEnabled
? BadgeVariant.success
: BadgeVariant.neutral,
label: album.uploadEnabled
? l10n.albumUploadsOn
: l10n.albumUploadsOff,
compact: true,
),
],
),
],
),
const SizedBox(height: 2),
Text(
album.publicToken.length > 10
? '${album.publicToken.substring(0, 10)}…'
: album.publicToken,
style: theme.textTheme.bodySmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
/// 默认语言标题(缺失时回退首个条目;无翻译时显示 ID 兜底)。
String get _title {
for (final t in album.translations) {
if (t.locale == album.defaultLocale) return t.title;
}
if (album.translations.isNotEmpty) return album.translations.first.title;
return album.id;
}
/// 相册时区的时间区间(结束未定显示「未定」占位)。
String _timeRange(AppLocalizations l10n) {
final locale = l10n.localeName;
@@ -272,64 +217,3 @@ class AlbumCard extends StatelessWidget {
return '$start ~ $end';
}
}
/// 状态标签:active 正常 / offline 下线。
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.status});
final AlbumStatus status;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final active = status == AlbumStatus.active;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: active
? theme.colorScheme.secondaryContainer
: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
active ? l10n.albumStatusActive : l10n.albumStatusOffline,
style: theme.textTheme.labelSmall?.copyWith(
color: active
? theme.colorScheme.onSecondaryContainer
: theme.colorScheme.onErrorContainer,
),
),
);
}
}
/// 开关标签:色调区分开/关。
class _SwitchChip extends StatelessWidget {
const _SwitchChip({required this.label, required this.on});
final String label;
final bool on;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: on
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: on
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant,
),
),
);
}
}
+40 -14
View File
@@ -4,32 +4,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/locale_controller.dart';
import '../core/api_client.dart';
import '../features/uploads/engine_scope.dart';
import '../features/uploads/queue_controller.dart';
import '../l10n/app_localizations.dart';
import 'albums_tab.dart';
import 'queue_tab.dart';
/// 主壳:底部导航(相册 / 队列 / 设置)。
class HomePage extends StatefulWidget {
/// 主壳:底部导航「相册 / 上传 / 设置」(UI-01 §4.1)。
///
/// IndexedStack 保持三页签状态;「上传」页签角标 = 当前账号未结束任务数
/// ([unfinishedCountOf],TaskStore 账号硬隔离,不跨账号),为 0 时隐藏。
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
class _HomePageState extends ConsumerState<HomePage> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final badgeCount = ref.watch(uploadBadgeCountProvider);
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: const [
AlbumsTab(),
QueueTab(),
SettingsTab(),
],
children: const [AlbumsTab(), QueueTab(), SettingsTab()],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
@@ -39,17 +40,23 @@ class _HomePageState extends State<HomePage> {
NavigationDestination(
icon: const Icon(Icons.photo_library_outlined),
selectedIcon: const Icon(Icons.photo_library),
label: l10n.homeTabAlbums,
label: l10n.navAlbums,
),
NavigationDestination(
icon: const Icon(Icons.cloud_upload_outlined),
selectedIcon: const Icon(Icons.cloud_upload),
label: l10n.homeTabQueue,
icon: _UploadTabIcon(
count: badgeCount,
icon: Icons.cloud_upload_outlined,
),
selectedIcon: _UploadTabIcon(
count: badgeCount,
icon: Icons.cloud_upload,
),
label: l10n.navUploads,
),
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: l10n.homeTabSettings,
label: l10n.navSettings,
),
],
),
@@ -57,6 +64,25 @@ class _HomePageState extends State<HomePage> {
}
}
/// 上传页签图标 + 未完成任务角标(≤0 时隐藏 Badge,仅图标)。
///
/// Semantics 携带本地化计数文案(读屏不依赖角标视觉)。
class _UploadTabIcon extends StatelessWidget {
const _UploadTabIcon({required this.count, required this.icon});
final int count;
final IconData icon;
@override
Widget build(BuildContext context) {
if (count <= 0) return Icon(icon);
return Semantics(
label: AppLocalizations.of(context).badgeUploading(count),
child: Badge.count(count: count, child: Icon(icon)),
);
}
}
/// 设置页(P3 Task 3:修改密码入口 + 退出登录;Task 10:语言切换)。
class SettingsTab extends ConsumerWidget {
const SettingsTab({super.key});
+78 -78
View File
@@ -12,6 +12,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/albums/album_format.dart';
import '../features/albums/models.dart';
import '../features/albums/providers.dart';
import '../features/uploads/engine_scope.dart';
@@ -31,7 +32,8 @@ class QueueTab extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final counts = ref.watch(taskCountsProvider);
final autoRetryable = (counts[UploadTaskState.retryableFailed] ?? 0) +
final autoRetryable =
(counts[UploadTaskState.retryableFailed] ?? 0) +
(counts[UploadTaskState.waitingNetwork] ?? 0);
return Scaffold(
appBar: AppBar(
@@ -40,8 +42,7 @@ class QueueTab extends ConsumerWidget {
IconButton(
tooltip: l10n.serverHistoryEntry,
icon: const Icon(Icons.history_outlined),
onPressed: () =>
Navigator.of(context).pushNamed('/server-history'),
onPressed: () => Navigator.of(context).pushNamed('/server-history'),
),
],
),
@@ -77,10 +78,15 @@ class _FilterBar extends ConsumerWidget {
children: [
Row(
children: [
Icon(Icons.filter_list,
size: 18, color: theme.colorScheme.onSurfaceVariant),
Icon(
Icons.filter_list,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(child: _AlbumDropdown(filter: filter, albums: albums)),
Expanded(
child: _AlbumDropdown(filter: filter, albums: albums),
),
],
),
const SizedBox(height: 4),
@@ -93,9 +99,8 @@ class _FilterBar extends ConsumerWidget {
ChoiceChip(
label: Text(_timeLabel(l10n, time)),
selected: filter.time == time,
onSelected: (_) => ref
.read(queueFilterProvider.notifier)
.setTime(time),
onSelected: (_) =>
ref.read(queueFilterProvider.notifier).setTime(time),
visualDensity: VisualDensity.compact,
),
],
@@ -159,10 +164,9 @@ class _AlbumDropdown extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final knownIds = albums.map((a) => a.id).toSet();
final value =
filter.albumId != null && knownIds.contains(filter.albumId)
? filter.albumId
: null;
final value = filter.albumId != null && knownIds.contains(filter.albumId)
? filter.albumId
: null;
return DropdownButton<String?>(
value: value,
isExpanded: true,
@@ -224,8 +228,7 @@ class _TaskListView extends ConsumerWidget {
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: tasks.length,
itemBuilder: (context, index) =>
_TaskTile(task: tasks[index]),
itemBuilder: (context, index) => _TaskTile(task: tasks[index]),
);
},
);
@@ -250,7 +253,8 @@ class _TaskTile extends ConsumerWidget {
final albumName = album != null
? albumDisplayTitle(album, l10n.localeName)
: task.albumId;
final failedLike = task.state == UploadTaskState.preparationFailed ||
final failedLike =
task.state == UploadTaskState.preparationFailed ||
task.state == UploadTaskState.retryableFailed ||
task.state == UploadTaskState.waitingNetwork;
@@ -258,11 +262,7 @@ class _TaskTile extends ConsumerWidget {
leading: _Thumb(
path: task.cleanedAt == null ? task.sourceFilePath : null,
),
title: Text(
task.filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
title: Text(task.filename, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -276,8 +276,9 @@ class _TaskTile extends ConsumerWidget {
albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
@@ -287,12 +288,13 @@ class _TaskTile extends ConsumerWidget {
Text(
task.attempt > 0
? '${l10n.messageForTaskError(task.lastError!)} · '
'${l10n.queueAttemptCount(task.attempt)}'
'${l10n.queueAttemptCount(task.attempt)}'
: l10n.messageForTaskError(task.lastError!),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
if (task.state == UploadTaskState.uploading ||
@@ -318,18 +320,18 @@ class _Thumb extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget placeholder() => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.image_outlined,
size: 24,
color: theme.colorScheme.onSurfaceVariant,
),
);
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.image_outlined,
size: 24,
color: theme.colorScheme.onSurfaceVariant,
),
);
if (path == null) return placeholder();
return ClipRRect(
borderRadius: BorderRadius.circular(6),
@@ -358,35 +360,33 @@ class _StateChip extends StatelessWidget {
final theme = Theme.of(context);
final (color, onColor) = switch (state) {
UploadTaskState.preparing => (
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant
),
UploadTaskState.preparationFailed ||
UploadTaskState.retryableFailed => (
theme.colorScheme.errorContainer,
theme.colorScheme.onErrorContainer
),
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant,
),
UploadTaskState.preparationFailed || UploadTaskState.retryableFailed => (
theme.colorScheme.errorContainer,
theme.colorScheme.onErrorContainer,
),
UploadTaskState.queued => (
theme.colorScheme.secondaryContainer,
theme.colorScheme.onSecondaryContainer
),
UploadTaskState.uploading ||
UploadTaskState.uploadedConfirming => (
theme.colorScheme.primaryContainer,
theme.colorScheme.onPrimaryContainer
),
theme.colorScheme.secondaryContainer,
theme.colorScheme.onSecondaryContainer,
),
UploadTaskState.uploading || UploadTaskState.uploadedConfirming => (
theme.colorScheme.primaryContainer,
theme.colorScheme.onPrimaryContainer,
),
UploadTaskState.completed => (
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer
),
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer,
),
UploadTaskState.waitingNetwork => (
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer
),
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer,
),
UploadTaskState.cancelled => (
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant
),
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant,
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
@@ -458,9 +458,7 @@ class _TaskActions extends ConsumerWidget {
icon: const Icon(Icons.refresh),
onPressed: account == null
? null
: () => ref
.read(uploadEngineProvider)
.retryNow(account, task.id),
: () => ref.read(uploadEngineProvider).retryNow(account, task.id),
);
case UploadTaskState.queued:
case UploadTaskState.preparing:
@@ -495,15 +493,11 @@ class _TaskActions extends ConsumerWidget {
if (sourcePath == null || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(uploadPreparerProvider).retry(
accountId: account,
taskId: task.id,
sourcePath: sourcePath,
);
await ref
.read(uploadPreparerProvider)
.retry(accountId: account, taskId: task.id, sourcePath: sourcePath);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
}
}
@@ -541,13 +535,17 @@ class _BatchRetryBar extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
Icon(Icons.error_outline, color: theme.colorScheme.onErrorContainer),
Icon(
Icons.error_outline,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${l10n.queueStateFailed} · $failed',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onErrorContainer),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
FilledButton.icon(
@@ -572,7 +570,9 @@ class _BatchRetryBar extends ConsumerWidget {
final account = ref.read(sessionAccountProvider).value;
if (account == null) return;
final engine = ref.read(uploadEngineProvider);
final tasks = await ref.read(taskStoreProvider).listByAccount(
final tasks = await ref
.read(taskStoreProvider)
.listByAccount(
account,
states: const {
UploadTaskState.retryableFailed,
@@ -0,0 +1,76 @@
/// 空态(UI-01):图标 + 标题 + 说明 + 可选操作按钮,居中布局。
///
/// 用于列表/工作区「暂无内容」的引导(相册为空、队列为空等);标题与
/// 说明由调用方传入(本地化),操作按钮为次强调(tonal,不与页面主
/// CTA 争夺层级)。与 ErrorState 的区别:空态是正常状态,无重试语义。
library;
import 'package:flutter/material.dart';
import '../tokens.dart';
/// 空态视图。
class EmptyState extends StatelessWidget {
const EmptyState({
required this.icon,
required this.title,
this.subtitle,
this.actionLabel,
this.onAction,
super.key,
});
/// 主题图标(如 [Icons.photo_library_outlined])。
final IconData icon;
/// 主文案(如「暂无相册」)。
final String title;
/// 补充说明;null 不占位。
final String? subtitle;
/// 可选操作按钮文案;null 不显示按钮。
final String? actionLabel;
/// 操作回调(与 [actionLabel] 成对提供)。
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(kSpace24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: kSpace12),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
if (subtitle != null && subtitle!.isNotEmpty) ...[
const SizedBox(height: kSpace4),
Text(
subtitle!,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (actionLabel != null) ...[
const SizedBox(height: kSpace16),
FilledButton.tonal(
onPressed: onAction,
child: Text(actionLabel!),
),
],
],
),
),
);
}
}
@@ -0,0 +1,63 @@
/// 错误态(UI-01):错误图标 + 说明 + 重试按钮(主强调)。
///
/// 用于加载失败等需要用户处理的场景。与 EmptyState 的区别:错误态有
/// 明确的重试动作(主按钮 + 刷新图标),文案为错误色语义;空态无重试。
/// 重试文案来自语言资源([AppLocalizations.retry])。
library;
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../tokens.dart';
/// 错误态视图。
class ErrorState extends StatelessWidget {
const ErrorState({
required this.message,
required this.onRetry,
this.icon,
super.key,
});
/// 错误说明(调用方本地化,如错误码映射文案)。
final String message;
/// 重试回调。
final VoidCallback onRetry;
/// 覆盖主题图标(默认 [Icons.cloud_off],加载失败语义)。
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final errorColor = theme.brightness == Brightness.light
? kColorError
: kColorErrorDark;
return Center(
child: Padding(
padding: const EdgeInsets.all(kSpace24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon ?? Icons.cloud_off, size: 48, color: errorColor),
const SizedBox(height: kSpace12),
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: kSpace16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).retry),
),
],
),
),
);
}
}
@@ -0,0 +1,67 @@
/// 网络照片缩略图(UI-01 §4.1):固定宽高比预留 + 占位 + 失败替代图。
///
/// 布局稳定性优先:占位不是转圈,而是同一宽高比的中性色块(网格/卡片
/// 不因加载状态跳动);加载完成前显示占位,失败显示中性色块 + 小图标
/// (不显示破图)。[url] 为 null(无封面/已删除)时常驻占位。使用
/// Image.network + errorBuilder(项目未引入 cached_network_image,保持
/// 零新增依赖)。
library;
import 'package:flutter/material.dart';
/// 网络缩略图:`AspectRatio` 包裹,cover 裁切。
class PhotoThumbnail extends StatelessWidget {
const PhotoThumbnail({
required this.url,
required this.aspectRatio,
this.fit = BoxFit.cover,
super.key,
});
/// 图片 URL;null 显示常驻占位(无封面/无预览语义)。
final String? url;
/// 宽高比(相册封面 16:9、网格 1:1 等)——占位与图片共用,尺寸恒定。
final double aspectRatio;
/// 图片适配(默认 cover 裁切;大图查看用 contain)。
final BoxFit fit;
@override
Widget build(BuildContext context) {
if (url == null) {
return AspectRatio(
aspectRatio: aspectRatio,
child: _placeholder(context),
);
}
return AspectRatio(
aspectRatio: aspectRatio,
child: Image.network(
url!,
fit: fit,
// 帧 ready 前显示中性占位(不是 spinner):布局稳定优先。
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
wasSynchronouslyLoaded || frame != null
? child
: _placeholder(context),
errorBuilder: (_, _, _) =>
_placeholder(context, icon: Icons.broken_image_outlined),
),
);
}
/// 中性占位色块 + 小图标(加载中 / 无封面 / 加载失败共用)。
Widget _placeholder(
BuildContext context, {
IconData icon = Icons.image_outlined,
}) {
final theme = Theme.of(context);
return ColoredBox(
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(icon, size: 24, color: theme.colorScheme.onSurfaceVariant),
),
);
}
}
@@ -0,0 +1,37 @@
/// 分组标题(UI-01 §3.1:分组标题 17-18):标题 + 可选行尾动作。
///
/// 标题取主题 titleMedium(w600);行尾动作由调用方构建(如「查看全部」
/// TextButton)。不自带内外边距——由页面布局决定(避免为每个 SizedBox
/// 建一层封装)。
library;
import 'package:flutter/material.dart';
/// 分组标题行。
class SectionHeader extends StatelessWidget {
const SectionHeader({required this.title, this.trailing, super.key});
/// 分组标题(本地化文案)。
final String title;
/// 行尾动作(可选)。
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
),
),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
],
);
}
}
@@ -0,0 +1,112 @@
/// 状态徽章(UI-01 §3.1):图标 + 文案 + 颜色三重表达,不只靠颜色。
///
/// 跨页面复用的语义变体(上传状态、公开/关闭等);文案由调用方传入
/// (本地化),图标按变体内置、可显式覆盖。两档尺寸:compact 高 24
/// (卡片角标行)、默认高 32(列表/详情行)。
library;
import 'package:flutter/material.dart';
import '../tokens.dart';
/// 徽章语义变体。
enum BadgeVariant {
/// 成功/完成事实(绿)。
success,
/// 等待中(琥珀)。
warning,
/// 需要处理的错误(红)。
error,
/// 中性/关闭/未启用(灰)。
neutral,
/// 信息提示(主色蓝)。
info,
}
/// 状态徽章:`图标 + 文案`,浅底色块(语义色 12% 透明度)+ 语义色文字。
class StateBadge extends StatelessWidget {
const StateBadge({
required this.variant,
required this.label,
this.icon,
this.compact = false,
super.key,
});
/// 语义变体(决定图标与颜色)。
final BadgeVariant variant;
/// 展示文案(调用方本地化)。
final String label;
/// 显式覆盖图标(默认按变体内置)。
final IconData? icon;
/// 紧凑档:高 24、字号 12;默认高 32、字号 13。
final bool compact;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (defaultIcon, color) = _resolve(variant, theme.brightness);
return Container(
height: compact ? 24.0 : 32.0,
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(kRadiusBadge),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon ?? defaultIcon, size: compact ? 14 : 16, color: color),
// 图标-文字光学间距(非布局节奏,不进令牌)。
SizedBox(width: compact ? kSpace4 : 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
(compact
? theme.textTheme.labelSmall
: theme.textTheme.bodySmall)
?.copyWith(color: color, fontWeight: FontWeight.w600),
),
),
],
),
);
}
/// 变体 → (图标, 语义色);深色主题取提亮变体保证对比度。
static (IconData, Color) _resolve(
BadgeVariant variant,
Brightness brightness,
) => switch (variant) {
BadgeVariant.success =>
brightness == Brightness.light
? (Icons.check_circle, kColorSuccess)
: (Icons.check_circle, kColorSuccessDark),
BadgeVariant.warning =>
brightness == Brightness.light
? (Icons.hourglass_top, kColorWaiting)
: (Icons.hourglass_top, kColorWaitingDark),
BadgeVariant.error =>
brightness == Brightness.light
? (Icons.error_outline, kColorError)
: (Icons.error_outline, kColorErrorDark),
BadgeVariant.neutral =>
brightness == Brightness.light
? (Icons.remove_circle_outline, kColorTextSecondary)
: (Icons.remove_circle_outline, kColorTextSecondaryDark),
BadgeVariant.info =>
brightness == Brightness.light
? (Icons.info_outline, kColorInfo)
: (Icons.info_outline, kColorInfoDark),
};
}
+83
View File
@@ -0,0 +1,83 @@
/// UI-01 设计令牌(方案 §3.1/§3.2):色彩、间距、圆角、动效时长。
///
/// - 语义色(成功/等待/错误)不占用 Material ColorScheme 角色(secondary
/// 虽承载等待色,但组件层统一走令牌),浅色值为方案基准;深色场景用
/// `*Dark` 变体——深色表面上直接使用浅色基准会跌破对比度目标,组件按
/// [Brightness] 选择(见 StateBadge)。
/// - 间距 4/8/12/16/24/32;页面左右默认 [kSpace16]。
/// - 圆角:卡片 16 / 输入框与按钮 12 / 小标签 8,避免全部做成胶囊。
/// - 动效:轻量状态 [kMotionFast]、轮播/照片过渡 [kMotionCarousel];
/// 系统「减少动态效果」开启时 [motion] 统一归零(禁用非必要动效)。
library;
import 'package:flutter/material.dart';
// ── 语义色(浅色基准,方案 §3.1)──────────────────────────────────────────
/// 成功:已上传等完成事实(#16794B)。
const Color kColorSuccess = Color(0xFF16794B);
/// 等待:等待网络、等待调度(#996300)。
const Color kColorWaiting = Color(0xFF996300);
/// 错误:需要用户处理的失败(#B42318)。
const Color kColorError = Color(0xFFB42318);
/// 信息:中性提示(与主色一致,#1A73E8)。
const Color kColorInfo = Color(0xFF1A73E8);
/// 分隔线:轻分隔,不用粗边框(#E5EAF1)。
const Color kColorDivider = Color(0xFFE5EAF1);
/// 次文字:时间、地点、说明(#5E6878)。
const Color kColorTextSecondary = Color(0xFF5E6878);
// ── 语义色(深色变体:深色表面对应提亮值)──────────────────────────────
/// 成功(深色表面用)。
const Color kColorSuccessDark = Color(0xFF34D399);
/// 等待(深色表面用)。
const Color kColorWaitingDark = Color(0xFFFCD34D);
/// 错误(深色表面用)。
const Color kColorErrorDark = Color(0xFFF87171);
/// 信息(深色表面用,与深色主色一致)。
const Color kColorInfoDark = Color(0xFF8AB4F8);
/// 次文字(深色表面用)。
const Color kColorTextSecondaryDark = Color(0xFFA6B0C0);
// ── 间距(4/8/12/16/24/32)───────────────────────────────────────────────
const double kSpace4 = 4.0;
const double kSpace8 = 8.0;
const double kSpace12 = 12.0;
const double kSpace16 = 16.0;
const double kSpace24 = 24.0;
const double kSpace32 = 32.0;
// ── 圆角(卡片 16 / 输入框与按钮 12 / 小标签 8)────────────────────────
const double kRadiusCard = 16.0;
const double kRadiusInput = 12.0;
const double kRadiusBadge = 8.0;
// ── 动效时长(轻量 150-220ms / 轮播 250-350ms)────────────────────────
/// 轻量状态过渡(选中态、chip 切换等)。
const Duration kMotionFast = Duration(milliseconds: 180);
/// 轮播/照片过渡。
const Duration kMotionCarousel = Duration(milliseconds: 300);
/// 「减少动态效果」开启时的时长(等价禁用非必要动效)。
const Duration kMotionReduce = Duration.zero;
/// 当前上下文的动效时长:默认 [kMotionFast],`carousel: true` 取
/// [kMotionCarousel];系统「减少动态效果」开启时一律 [kMotionReduce]。
Duration motion(BuildContext context, {bool carousel = false}) {
if (MediaQuery.disableAnimationsOf(context)) return kMotionReduce;
return carousel ? kMotionCarousel : kMotionFast;
}