/// 相册列表页签:游标加载更多、下拉刷新、整图封面卡(UI-03 §4 页面 05)。 /// /// 卡片为 16:9 全幅封面(无封面同尺寸中性占位),标题(白,≤2 行,语言 /// 回退链)/时间场地(white70 单行)/公开与上传状态小徽章叠加在底部暗色 /// 渐变上——文字区域高于基准时卡片增高,不截掉徽章。手机单列;可用宽度 /// > 600 双列(行内卡片等高)。publicToken 与四开关堆叠不在主卡片(完整 /// 配置在详情设置)。 library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/error_l10n.dart'; 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/tokens.dart'; import 'album_detail_page.dart'; import 'album_form_page.dart'; /// 双列布局的可用宽度阈值(方案 §4.1「平板按可用宽度使用两列」)。 const double kAlbumTwoColumnMinWidth = 600; /// 首页「相册」页签(UI-03 页面 05):标题 + 副标题,右上「新建」圆形 /// 入口;空态 CTA 与之共用 [AlbumsTab.openCreate]。 class AlbumsTab extends ConsumerWidget { const AlbumsTab({super.key}); /// 打开创建表单;成功后刷新列表(空态 CTA 与右上入口共用)。 static Future openCreate(BuildContext context, WidgetRef ref) async { final changed = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const AlbumFormPage()), ); if (changed == true) { ref.read(albumListProvider.notifier).refresh(); } } @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final state = ref.watch(albumListProvider); return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(l10n.albums), Text( l10n.albumsSubtitle, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), actions: [ Padding( padding: const EdgeInsets.only(right: kSpace16), child: IconButton.filled( tooltip: l10n.albumCreate, onPressed: () => openCreate(context, ref), icon: const Icon(Icons.add), ), ), ], ), body: AlbumListBody(state: state), ); } } /// 列表主体:按状态分发 加载/错误/空/列表(含加载更多页脚)。 /// /// 空态带「新建相册」入口;错误态带重试。列表按可用宽度切换单/双列, /// 行结构 [ListView.builder](行 = 1-2 张卡片),保证下拉刷新与游标 /// 加载更多行为与旧版一致。 class AlbumListBody extends ConsumerWidget { const AlbumListBody({required this.state, super.key}); final AlbumListState state; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final notifier = ref.read(albumListProvider.notifier); if (state.loading) { return const Center(child: CircularProgressIndicator()); } if (state.items.isEmpty) { if (state.errorCode != null) { return ErrorState( message: l10n.messageForErrorCode(state.errorCode!), onRetry: notifier.refresh, ); } // 空态也保留下拉刷新(被拉进相册后可手动刷新看到,2026-09-23 用户报告)。 return RefreshIndicator( onRefresh: notifier.refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ SizedBox( height: MediaQuery.of(context).size.height * 0.5, child: EmptyState( icon: Icons.photo_library_outlined, title: l10n.albumsEmpty, actionLabel: l10n.albumCreate, onAction: () => AlbumsTab.openCreate(context, ref), ), ), ], ), ); } 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; // 双列并排:卡片高度由 16:9 封面主导、各自自撑(不用 // IntrinsicHeight 等高——整图卡内含 LayoutBuilder 不支持 // intrinsic 计算;文字区域高于基准的极端大字体下允许行内 // 微小高度差)。 return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: AlbumCard(album: state.items[first])), if (hasSecond) ...[ const SizedBox(width: kSpace12), Expanded(child: AlbumCard(album: state.items[first + 1])), ], ], ); }, ), ); }, ); } } /// 相册卡片(UI-03 §4 页面 05):整卡 16:9 封面 + 底部暗色渐变叠层。 /// /// 图片铺满整卡(无封面/加载失败为同尺寸中性占位),标题/时间场地/状态 /// 徽章以白字叠加在渐变上;文字区域高于 16:9 基准时卡片增高(大字体下 /// 不截断徽章)。标题按「界面语言 → 相册默认语言 → 首个条目」回退;时间 /// 用相册时区;公开/上传分别显示(图标 + 文案,不只靠颜色)。publicToken /// 与四开关堆叠已从主卡片移除(完整配置在详情设置)。 class AlbumCard extends StatelessWidget { const AlbumCard({required this.album, super.key}); final AlbumSummary album; /// 底部渐变:透明 → black54,只压暗下半部(白字对比度)。 /// UI06:app-preview .scrim——30% 起渐变到 #0C1728E8(只压暗下部)。 static const LinearGradient _scrim = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: [0.30, 1.0], colors: [Color(0x00000000), Color(0xE80C1728)], ); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final venue = albumDisplayVenue(album, l10n.localeName); final meta = venue == null ? _timeRange(l10n) : '${_timeRange(l10n)} · $venue'; return Card( margin: const EdgeInsets.symmetric(vertical: kSpace8), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumDetailPage(albumId: album.id), ), ), child: LayoutBuilder( builder: (context, constraints) { // 16:9 基准高度;作为 Stack 首个子节点参与 IntrinsicHeight // 双列等高(RenderStack 的 intrinsic 只看首子节点)。 final baseHeight = constraints.maxWidth * 9 / 16; return Stack( alignment: Alignment.bottomLeft, children: [ SizedBox(width: double.infinity, height: baseHeight), Positioned.fill( child: PhotoThumbnail( url: album.cover?.url, aspectRatio: 16 / 9, ), ), const Positioned.fill( child: DecoratedBox( decoration: BoxDecoration(gradient: _scrim), ), ), Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace16, kSpace16, kSpace12, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( albumDisplayTitle(album, l10n.localeName), maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), ), const SizedBox(height: kSpace4), Text( meta, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: Colors.white70, ), ), const SizedBox(height: kSpace8), Row( children: [ _OverlayBadge( icon: album.publicEnabled ? Icons.info_outline : Icons.remove_circle_outline, color: album.publicEnabled ? kColorInfoDark : Colors.white70, label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff, ), const SizedBox(width: kSpace8), _OverlayBadge( icon: album.uploadEnabled ? Icons.check_circle : Icons.remove_circle_outline, color: album.uploadEnabled ? kColorSuccessDark : Colors.white70, label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff, ), ], ), ], ), ), ], ); }, ), ), ); } /// 相册时区的时间区间(结束未定显示「未定」占位)。 String _timeRange(AppLocalizations l10n) { final locale = l10n.localeName; final start = formatAlbumDateTime(album.startAt, album.timezone, locale); final end = album.endAt == null ? l10n.notSet : formatAlbumDateTime(album.endAt!, album.timezone, locale); return '$start ~ $end'; } } /// 图片叠层上的状态小徽章:半透明深色底 + 白字(语义色仅用于图标, /// 提亮变体保证在照片上的对比度)。 class _OverlayBadge extends StatelessWidget { const _OverlayBadge({ required this.icon, required this.color, required this.label, }); final IconData icon; final Color color; final String label; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( padding: const EdgeInsets.symmetric(horizontal: kSpace8, vertical: 4), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.35), borderRadius: BorderRadius.circular(kRadiusBadge), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: color), const SizedBox(width: kSpace4), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), ), ], ), ); } }