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
+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,