feat(mobile): UI-01 album cards, photo grid and full viewer
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1 样板)。
|
||||
/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1)。
|
||||
///
|
||||
/// 卡片顺序:封面(16:9,无封面中性占位)→ 标题(≤2 行,语言回退链)→
|
||||
/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。完整列表
|
||||
/// 交互(平板两列、错误重试等)随后续任务迭代。
|
||||
/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。手机单列;
|
||||
/// 可用宽度 > 600 双列(行内卡片等高,不挤压英文标题)。publicToken 与
|
||||
/// 四开关堆叠不在主卡片(完整配置在详情设置)。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -21,11 +22,15 @@ import '../ui/tokens.dart';
|
||||
import 'album_detail_page.dart';
|
||||
import 'album_form_page.dart';
|
||||
|
||||
/// 双列布局的可用宽度阈值(方案 §4.1「平板按可用宽度使用两列」)。
|
||||
const double kAlbumTwoColumnMinWidth = 600;
|
||||
|
||||
/// 首页「相册」页签。
|
||||
class AlbumsTab extends ConsumerWidget {
|
||||
const AlbumsTab({super.key});
|
||||
|
||||
Future<void> _openCreate(BuildContext context, WidgetRef ref) async {
|
||||
/// 打开创建表单;成功后刷新列表(空态 CTA 与 FAB 共用)。
|
||||
static Future<void> openCreate(BuildContext context, WidgetRef ref) async {
|
||||
final changed = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(builder: (_) => const AlbumFormPage()),
|
||||
);
|
||||
@@ -41,7 +46,7 @@ class AlbumsTab extends ConsumerWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.albums)),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _openCreate(context, ref),
|
||||
onPressed: () => openCreate(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(l10n.albumCreate),
|
||||
),
|
||||
@@ -51,6 +56,10 @@ class AlbumsTab extends ConsumerWidget {
|
||||
}
|
||||
|
||||
/// 列表主体:按状态分发 加载/错误/空/列表(含加载更多页脚)。
|
||||
///
|
||||
/// 空态带「新建相册」入口;错误态带重试。列表按可用宽度切换单/双列,
|
||||
/// 行结构 [ListView.builder](行 = 1-2 张卡片),保证下拉刷新与游标
|
||||
/// 加载更多行为与旧版一致。
|
||||
class AlbumListBody extends ConsumerWidget {
|
||||
const AlbumListBody({required this.state, super.key});
|
||||
|
||||
@@ -74,49 +83,75 @@ class AlbumListBody extends ConsumerWidget {
|
||||
return EmptyState(
|
||||
icon: Icons.photo_library_outlined,
|
||||
title: l10n.albumsEmpty,
|
||||
actionLabel: l10n.albumCreate,
|
||||
onAction: () => AlbumsTab.openCreate(context, ref),
|
||||
);
|
||||
}
|
||||
|
||||
final hasFooter = state.hasMore || state.loadMoreFailed;
|
||||
return RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: state.items.length + (hasFooter ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= state.items.length) {
|
||||
if (state.loadMoreFailed) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: kSpace16,
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final twoColumns = constraints.maxWidth > kAlbumTwoColumnMinWidth;
|
||||
final rowCount = (state.items.length / (twoColumns ? 2 : 1)).ceil();
|
||||
final hasFooter = state.hasMore || state.loadMoreFailed;
|
||||
return RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16,
|
||||
kSpace8,
|
||||
kSpace16,
|
||||
kSpace32,
|
||||
),
|
||||
itemCount: rowCount + (hasFooter ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= rowCount) {
|
||||
if (state.loadMoreFailed) {
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.error_outline),
|
||||
title: Text(l10n.loadMoreFailed),
|
||||
onTap: notifier.loadMore,
|
||||
);
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: kSpace16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// 接近底部时预取下一页(loadMore 自带并发/末页守卫)。
|
||||
if (index >= rowCount - 2 && state.hasMore && !state.loadingMore) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => notifier.loadMore(),
|
||||
);
|
||||
}
|
||||
if (!twoColumns) {
|
||||
return AlbumCard(album: state.items[index]);
|
||||
}
|
||||
final first = index * 2;
|
||||
final hasSecond = first + 1 < state.items.length;
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: AlbumCard(album: state.items[first])),
|
||||
if (hasSecond) ...[
|
||||
const SizedBox(width: kSpace12),
|
||||
Expanded(child: AlbumCard(album: state.items[first + 1])),
|
||||
],
|
||||
],
|
||||
),
|
||||
leading: const Icon(Icons.error_outline),
|
||||
title: Text(l10n.loadMoreFailed),
|
||||
onTap: notifier.loadMore,
|
||||
);
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: kSpace16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// 接近底部时预取下一页(loadMore 自带并发/末页守卫)。
|
||||
if (index >= state.items.length - 3 &&
|
||||
state.hasMore &&
|
||||
!state.loadingMore) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => notifier.loadMore(),
|
||||
);
|
||||
}
|
||||
return AlbumCard(album: state.items[index]);
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -140,10 +175,7 @@ class AlbumCard extends StatelessWidget {
|
||||
? _timeRange(l10n)
|
||||
: '${_timeRange(l10n)} · $venue';
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: kSpace16,
|
||||
vertical: kSpace8,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: kSpace8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
|
||||
Reference in New Issue
Block a user