/// 相册详情页(UI-03 §4 页面 06-08 重设计):顶栏返回 + 标题 +「⋯」 /// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再 /// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口 /// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览 /// (≤9 张)+ See all →(管理者)分类管理区;底部固定「拍摄上传(主)/ /// 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。 /// /// 空相册(页面 08):保留真实封面与资料,中心安静占位「Ready for your /// first photo」;协作者显示只读说明;有处理中照片时显示「处理中」,不 /// 当绝对空白,无伪造计数与示例照片。 /// /// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份—— /// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的 /// 上传)。编辑与分类变更后刷新本地详情并同步全局相册列表。 library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api_client.dart'; import '../core/app_exception.dart'; import '../core/error_codes.dart'; import '../core/error_l10n.dart'; import '../features/albums/album_format.dart'; import '../features/albums/models.dart'; import '../features/albums/providers.dart'; import '../features/photos/models.dart'; import '../features/uploads/enqueue.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/photo_thumbnail.dart'; import '../ui/components/section_header.dart'; import '../ui/components/state_badge.dart'; import '../ui/tokens.dart'; import 'album_form_page.dart'; import 'album_members_page.dart'; import 'album_photos_page.dart'; import 'capture_page.dart'; import 'share_qr_dialog.dart'; /// 详情页最近照片三列网格取数上限(方案 §4.2「最多 6-9 张」)。 const int _kRecentGridLimit = 9; /// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。 enum AlbumAction { edit, members, categories, share } /// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。 class AlbumDetailPage extends ConsumerStatefulWidget { const AlbumDetailPage({required this.albumId, super.key}); final String albumId; @override ConsumerState createState() => _AlbumDetailPageState(); } class _AlbumDetailPageState extends ConsumerState { final GlobalKey _categoriesKey = GlobalKey(); final ScrollController _scrollController = ScrollController(); Album? _album; bool _loading = true; String? _errorCode; /// 最近照片网格(协作者为「自己上传」,服务端过滤)。 List _recentPhotos = const []; bool _photosLoading = true; /// 管理者(owner/admin):scope=all 照片请求成功推导;403 → 协作者。 /// 预览请求非 403 失败时保守按管理者展示(服务端仍逐请求裁决)。 bool _isManager = true; @override void initState() { super.initState(); _load(); } @override void dispose() { _scrollController.dispose(); super.dispose(); } Future _load() async { setState(() { _loading = true; _errorCode = null; }); try { final album = await ref.read(apiClientProvider).getAlbum( widget.albumId, ); if (!mounted) return; setState(() { _album = album; _loading = false; }); await _loadPhotoPreview(); } on AppException catch (e) { if (mounted) { setState(() { _errorCode = e.code; _loading = false; }); } } on Object { if (mounted) { setState(() { _errorCode = ErrorCodes.internalError; _loading = false; }); } } } /// 照片入口预览:先按 all(owner/admin);403 回退 own(协作者仅自己 /// 上传)。非 403 失败不阻塞详情页(入口退化为「查看全部」按钮)。 Future _loadPhotoPreview() async { final album = _album; if (album == null) return; final api = ref.read(apiClientProvider); try { final page = await api.listAlbumPhotos( album.id, scope: AlbumPhotoScope.all, limit: _kRecentGridLimit, ); if (!mounted) return; setState(() { _recentPhotos = page.items; _photosLoading = false; _isManager = true; }); } on AppException catch (e) { if (!mounted) return; if (e.code != ErrorCodes.authForbidden) { setState(() { _recentPhotos = const []; _photosLoading = false; }); return; } try { final ownPage = await api.listAlbumPhotos( album.id, scope: AlbumPhotoScope.own, limit: _kRecentGridLimit, ); if (!mounted) return; setState(() { _recentPhotos = ownPage.items; _photosLoading = false; _isManager = false; }); } on AppException { if (!mounted) return; setState(() { _recentPhotos = const []; _photosLoading = false; _isManager = false; }); } } on Object { if (mounted) { setState(() { _recentPhotos = const []; _photosLoading = false; }); } } } // ── 导航入口(四入口与操作菜单共享,不另起一套实现) ─────────────── /// 编辑成功后:详情重载 + 全局列表同步。 Future _openEdit() async { final album = _album; if (album == null) return; final changed = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumFormPage(existing: album), ), ); if (changed == true) { await _load(); ref.read(albumListProvider.notifier).refresh(); } } Future _openMembers() async { final album = _album; if (album == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumMembersPage(album: album), ), ); // 成员变更不影响详情数据,回来无需刷新。 } Future _openPhotos() async { final album = _album; if (album == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumPhotosPage(album: album), ), ); // 管理页内可能发生隐藏/删除,回来后刷新预览网格。 await _loadPhotoPreview(); } void _shareQr() { final album = _album; if (album == null) return; showDialog( context: context, builder: (_) => ShareQrDialog( publicToken: album.publicToken, albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName), ), ); } /// 「⋯」操作菜单(页面 07):BottomSheet 按角色过滤动作;先关面板再 /// 导航(await pop 后统一分发),避免叠层路由错乱。 Future _showActionMenu() async { final action = await showModalBottomSheet( context: context, showDragHandle: true, builder: (_) => AlbumActionSheet(isManager: _isManager), ); if (!mounted || action == null) return; switch (action) { case AlbumAction.edit: await _openEdit(); case AlbumAction.members: await _openMembers(); case AlbumAction.categories: _scrollToCategories(); case AlbumAction.share: _shareQr(); } } // ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ───────── /// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。 Future _currentAccountId() async { final session = await ref.read(sessionStoreProvider).read(); return session?.account; } /// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。 Future _openCapture() async { final album = _album; if (album == null) return; final account = await _currentAccountId(); if (!mounted) return; if (account == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => CapturePage( target: CaptureTarget( accountId: account, albumId: album.id, albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName), categories: album.categories, ), ), ), ); } /// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。 Future _pickFromGallery() async { final album = _album; if (album == null) return; final l10n = AppLocalizations.of(context); final account = await _currentAccountId(); if (account == null) return; final paths = await ref.read(multiImagePickerProvider)(); if (paths.isEmpty || !mounted) return; final messenger = ScaffoldMessenger.of(context); final count = await enqueueForUpload( ref, accountId: account, albumId: album.id, sourcePaths: paths, ); if (!mounted) return; messenger.showSnackBar( SnackBar(content: Text(l10n.uploadEnqueuedCount(count))), ); } void _scrollToCategories() { final ctx = _categoriesKey.currentContext; if (ctx != null) { Scrollable.ensureVisible( ctx, duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, ); } } Future _deleteCategory(Category category) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( context: context, builder: (_) => AlertDialog( title: Text(l10n.delete), content: Text(l10n.deleteConfirmCategory), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(l10n.cancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: Text(l10n.delete), ), ], ), ); if (confirmed != true || !mounted) return; final messenger = ScaffoldMessenger.of(context); try { await ref .read(apiClientProvider) .deleteCategory(category.id); await _load(); } on AppException catch (e) { messenger.showSnackBar( SnackBar(content: Text(l10n.messageForError(e))), ); } on Object { messenger.showSnackBar( SnackBar(content: Text(l10n.errorGeneric)), ); } } Future _openCategoryDialog([Category? existing]) async { final album = _album; if (album == null) return; final changed = await showDialog( context: context, builder: (_) => CategoryEditDialog(albumId: album.id, existing: existing), ); if (changed == true) await _load(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final album = _album; final title = album == null ? l10n.albumDetail : albumDisplayTitle(album, l10n.localeName); return Scaffold( appBar: AppBar( title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), actions: [ IconButton( tooltip: l10n.moreActions, onPressed: _showActionMenu, icon: const Icon(Icons.more_vert), ), ], ), bottomNavigationBar: album == null ? null : _UploadBar( uploadEnabled: album.uploadEnabled, onCapture: _openCapture, onPick: _pickFromGallery, ), body: _loading ? const Center(child: CircularProgressIndicator()) : album == null ? _ErrorRetryView( message: l10n.messageForErrorCode(_errorCode!), onRetry: _load, ) : SingleChildScrollView( controller: _scrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 封面:全宽 16:9(无封面同尺寸中性占位)。 PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9), Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace16, kSpace16, 0, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( albumDisplayTitle(album, l10n.localeName), style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: kSpace4), Text( _metaLine(album, l10n), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: Theme.of(context) .colorScheme .onSurfaceVariant, ), ), const SizedBox(height: kSpace12), _StatusSection(album: album), ], ), ), const SizedBox(height: kSpace16), // 次级四入口(Photos/Members/Categories/Share)。 Padding( padding: const EdgeInsets.symmetric( horizontal: kSpace16, ), child: _ActionTiles( isManager: _isManager, onPhotos: _openPhotos, onMembers: _openMembers, onCategories: _scrollToCategories, onShare: _shareQr, ), ), const SizedBox(height: kSpace24), // Recent photos:三列网格(≤9)/ 空相册安静占位 / // 处理中指示。 Padding( padding: const EdgeInsets.symmetric( horizontal: kSpace16, ), child: _RecentPhotosSection( photos: _recentPhotos, loading: _photosLoading, isManager: _isManager, onOpenPhotos: _openPhotos, ), ), if (_isManager) ...[ const SizedBox(height: kSpace24), Padding( padding: const EdgeInsets.symmetric( horizontal: kSpace16, ), child: _CategoriesSection( key: _categoriesKey, album: album, onAdd: () => _openCategoryDialog(), onEdit: (c) => _openCategoryDialog(c), onDelete: _deleteCategory, ), ), ], const SizedBox(height: kSpace32), ], ), ), ); } /// 单行时间/场地(相册时区墙钟 + 场地语言回退;结束未定显示「未定」)。 static String _metaLine(Album album, AppLocalizations l10n) { final venue = albumDisplayVenue(album, l10n.localeName); final start = formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName); final end = album.endAt == null ? l10n.notSet : formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName); return venue == null ? '$start ~ $end (${album.timezone})' : '$start ~ $end (${album.timezone}) · $venue'; } } /// 操作菜单(UI-03 §4 页面 07):Edit album / Manage members / /// Categories / Share album + Cancel,按角色过滤(管理类动作仅 /// owner/admin;分享对全体成员开放)。点击 pop 对应 [AlbumAction], /// 由详情页在面板关闭后统一导航。 class AlbumActionSheet extends StatelessWidget { const AlbumActionSheet({required this.isManager, super.key}); /// 是否管理者(owner/admin):决定管理类动作是否显示。 final bool isManager; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); // 大字体/小屏下面板内容可滚动,不溢出。 return SafeArea( top: false, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (isManager) ...[ ListTile( leading: const Icon(Icons.edit_outlined), title: Text(l10n.editAlbum), onTap: () => Navigator.of(context).pop(AlbumAction.edit), ), ListTile( leading: const Icon(Icons.group_outlined), title: Text(l10n.manageMembers), onTap: () => Navigator.of(context).pop(AlbumAction.members), ), ListTile( leading: const Icon(Icons.category_outlined), title: Text(l10n.actionCategories), onTap: () => Navigator.of(context).pop(AlbumAction.categories), ), ], ListTile( leading: const Icon(Icons.share_outlined), title: Text(l10n.shareAlbum), onTap: () => Navigator.of(context).pop(AlbumAction.share), ), const Divider(indent: kSpace16, endIndent: kSpace16), ListTile( title: Center( child: Text( l10n.cancel, style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), onTap: () => Navigator.of(context).pop(), ), ], ), ), ); } } /// 状态区:公开 / 上传分别显示(关闭不等于不公开)。 class _StatusSection extends StatelessWidget { const _StatusSection({required this.album}); final Album album; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Wrap( spacing: kSpace8, runSpacing: kSpace8, children: [ StateBadge( variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral, label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff, ), StateBadge( variant: album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral, label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff, ), if (album.status == AlbumStatus.offline) StateBadge(variant: BadgeVariant.error, label: l10n.albumStatusOffline), ], ); } } /// 次级四入口(UI-03 §4 页面 06):Photos / Members / Categories / Share /// 等宽平铺,每格有效触控区 ≥44dp(实际高 72)。管理类入口(成员/分类) /// 仅管理者可见;Photos 与 Share 对全体成员开放。 class _ActionTiles extends StatelessWidget { const _ActionTiles({ required this.isManager, required this.onPhotos, required this.onMembers, required this.onCategories, required this.onShare, }); final bool isManager; final VoidCallback onPhotos; final VoidCallback onMembers; final VoidCallback onCategories; final VoidCallback onShare; @override Widget build(BuildContext context) { return Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, child: Row( children: [ Expanded( child: _ActionTile( icon: Icons.photo_library_outlined, label: AppLocalizations.of(context).actionPhotos, onTap: onPhotos, ), ), if (isManager) ...[ const VerticalDivider(width: 1), Expanded( child: _ActionTile( icon: Icons.group_outlined, label: AppLocalizations.of(context).actionMembers, onTap: onMembers, ), ), const VerticalDivider(width: 1), Expanded( child: _ActionTile( icon: Icons.category_outlined, label: AppLocalizations.of(context).actionCategories, onTap: onCategories, ), ), ], const VerticalDivider(width: 1), Expanded( child: _ActionTile( icon: Icons.share_outlined, label: AppLocalizations.of(context).actionShare, onTap: onShare, ), ), ], ), ); } } /// 单个入口格:圆角容器图标 + 标签纵排(≥44dp 触控要求)。 class _ActionTile extends StatelessWidget { const _ActionTile({ required this.icon, required this.label, required this.onTap, }); final IconData icon; final String label; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); return InkWell( onTap: onTap, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 72), child: Padding( padding: const EdgeInsets.symmetric(vertical: kSpace12), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(kRadiusInput), ), child: Icon( icon, size: 22, color: theme.colorScheme.primary, ), ), const SizedBox(height: kSpace8), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, ), ), ], ), ), ), ); } } /// Recent photos(UI-03 §4 页面 06/08): /// - 加载中 → 占位高度居中转圈; /// - 有照片 → 三列网格(≤9 张,真实缩略图;URL 失败为中性占位), /// 其中存在服务端处理中的照片时在网格上方提示「Processing...」; /// - 无照片 → 安静空态(页面 08):图标 + 「Ready for your first photo」, /// 协作者显示只读说明;不显示伪造计数与示例照片。 class _RecentPhotosSection extends StatelessWidget { const _RecentPhotosSection({ required this.photos, required this.loading, required this.isManager, required this.onOpenPhotos, }); final List photos; final bool loading; final bool isManager; final VoidCallback onOpenPhotos; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SectionHeader( title: l10n.recentPhotos, trailing: TextButton( onPressed: onOpenPhotos, child: Text(l10n.seeAllPhotos), ), ), const SizedBox(height: kSpace8), if (loading) const SizedBox( height: 120, child: Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ) else if (photos.isNotEmpty) ...[ if (_hasProcessing) Padding( padding: const EdgeInsets.only(bottom: kSpace8), child: Row( children: [ Icon( Icons.hourglass_top, size: 16, color: Theme.of(context).colorScheme.secondary, ), const SizedBox(width: kSpace8), Text( l10n.processingPhotos, style: Theme.of(context).textTheme.bodySmall, ), ], ), ), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: kSpace8, crossAxisSpacing: kSpace8, ), itemCount: photos.length, itemBuilder: (context, index) => PhotoThumbnail( url: photos[index].preview?.thumb.url, aspectRatio: 1, ), ), ] else Padding( padding: const EdgeInsets.symmetric(vertical: kSpace24), child: Column( children: [ Icon( Icons.photo_camera_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(height: kSpace12), Text( l10n.emptyAlbumTitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: kSpace4), Text( isManager ? l10n.emptyAlbumSubtitle : l10n.readonlyMember, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ), ], ); } /// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。 bool get _hasProcessing => photos.any( (p) => p.processingState == PhotoProcessingState.processing, ); } /// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传 /// 关闭时给出原因说明并禁用两个入口。 class _UploadBar extends StatelessWidget { const _UploadBar({ required this.uploadEnabled, required this.onCapture, required this.onPick, }); final bool uploadEnabled; final VoidCallback onCapture; final VoidCallback onPick; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!uploadEnabled) Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace8, kSpace16, 0, ), child: Row( children: [ Icon( Icons.info_outline, size: 16, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: kSpace4), Expanded( child: Text( l10n.albumUploadDisabledHint, style: theme.textTheme.bodySmall, ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace8, kSpace16, kSpace12, ), child: Row( children: [ Expanded( child: FilledButton.icon( onPressed: uploadEnabled ? onCapture : null, icon: const Icon(Icons.photo_camera_outlined), label: Text(l10n.uploadActionCapture), ), ), const SizedBox(width: kSpace12), Expanded( child: OutlinedButton.icon( onPressed: uploadEnabled ? onPick : null, icon: const Icon(Icons.photo_library_outlined), label: Text(l10n.uploadActionPick), ), ), ], ), ), ], ), ); } } /// 首屏错误 + 重试。 class _ErrorRetryView extends StatelessWidget { const _ErrorRetryView({required this.message, required this.onRetry}); final String message; final Future 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 _CategoriesSection extends StatelessWidget { const _CategoriesSection({ super.key, required this.album, required this.onAdd, required this.onEdit, required this.onDelete, }); final Album album; final VoidCallback onAdd; final void Function(Category category) onEdit; final Future Function(Category category) onDelete; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( l10n.albumCategories, style: theme.textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w600), ), ), IconButton( tooltip: l10n.categoryAdd, onPressed: onAdd, icon: const Icon(Icons.add_circle_outline), ), ], ), if (album.categories.isEmpty) Padding( padding: const EdgeInsets.only(top: 8), child: Text( l10n.categoryEmpty, style: theme.textTheme.bodySmall ?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ) else Card( margin: EdgeInsets.zero, child: Column( children: [ for (final (i, category) in album.categories.indexed) ...[ if (i > 0) const Divider(height: 1, indent: 16, endIndent: 16), ListTile( dense: true, leading: SizedBox( width: 40, child: Text( '${category.sortOrder}', textAlign: TextAlign.center, style: theme.textTheme.labelMedium?.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ), ), ), title: Text(_namesOf(category)), onTap: () => onEdit(category), trailing: IconButton( tooltip: l10n.delete, icon: const Icon(Icons.delete_outline), onPressed: () => onDelete(category), ), ), ], ], ), ), ], ); } /// 分类名展示:zh / en 名称以「 · 」连接。 static String _namesOf(Category category) { final names = category.translations.map((t) => t.name).toList(); return names.isEmpty ? category.id : names.join(' · '); } } /// 分类新增/编辑对话框:排序序号 + 中文名 + 英文名(至少一个必填)。 /// /// 成功 pop(true);校验失败就地提示;API 失败 SnackBar(对话框保持)。 class CategoryEditDialog extends ConsumerStatefulWidget { const CategoryEditDialog({ required this.albumId, this.existing, super.key, }); final String albumId; /// 编辑目标;null = 新增。 final Category? existing; @override ConsumerState createState() => _CategoryEditDialogState(); } class _CategoryEditDialogState extends ConsumerState { late final TextEditingController _sortOrder = TextEditingController(text: (widget.existing?.sortOrder ?? 0).toString()); late final TextEditingController _zhName = TextEditingController( text: widget.existing?.translations .where((t) => t.locale == 'zh-CN') .firstOrNull ?.name ?? '', ); late final TextEditingController _enName = TextEditingController( text: widget.existing?.translations .where((t) => t.locale == 'en') .firstOrNull ?.name ?? '', ); String? _error; bool _submitting = false; @override void dispose() { _sortOrder.dispose(); _zhName.dispose(); _enName.dispose(); super.dispose(); } Future _submit() async { final l10n = AppLocalizations.of(context); final zh = _zhName.text.trim(); final en = _enName.text.trim(); if (zh.isEmpty && en.isEmpty) { setState(() => _error = l10n.categoryNameRequired); return; } final sortOrder = int.tryParse(_sortOrder.text.trim()); if (sortOrder == null) { setState(() => _error = l10n.categorySortOrderInvalid); return; } setState(() { _error = null; _submitting = true; }); final messenger = ScaffoldMessenger.of(context); final navigator = Navigator.of(context); final translations = [ if (zh.isNotEmpty) CategoryTranslation(locale: 'zh-CN', name: zh), if (en.isNotEmpty) CategoryTranslation(locale: 'en', name: en), ]; try { final api = ref.read(apiClientProvider); if (widget.existing == null) { await api.createCategory( widget.albumId, CategoryCreateRequest( sortOrder: sortOrder, translations: translations, ), ); } else { await api.updateCategory( widget.existing!.id, CategoryUpdateRequest( sortOrder: sortOrder, translations: translations, ), ); } navigator.pop(true); } on AppException catch (e) { messenger.showSnackBar( SnackBar(content: Text(l10n.messageForError(e))), ); if (mounted) setState(() => _submitting = false); } on Object { messenger.showSnackBar( SnackBar(content: Text(l10n.errorGeneric)), ); if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return AlertDialog( title: Text(widget.existing == null ? l10n.categoryAdd : l10n.categoryEdit), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( controller: _sortOrder, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: l10n.categorySortOrder, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), TextFormField( controller: _zhName, decoration: InputDecoration( labelText: l10n.categoryNameZh, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), TextFormField( controller: _enName, decoration: InputDecoration( labelText: l10n.categoryNameEn, border: const OutlineInputBorder(), ), ), if (_error != null) ...[ const SizedBox(height: 8), Text( _error!, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ], ], ), actions: [ TextButton( onPressed: _submitting ? null : () => Navigator.of(context).pop(), child: Text(l10n.cancel), ), FilledButton( onPressed: _submitting ? null : _submit, child: _submitting ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(l10n.save), ), ], ); } }