feat(mobile): UI-01 theme system, shared components and navigation
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user