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;
|
||||
}
|
||||
Reference in New Issue
Block a user