Files
photo-live/apps/mobile/lib/pages/album_detail_page.dart
T
2026-09-21 17:20:43 +08:00

985 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 相册详情页(UI-01 §4.2 重构):紧凑封面与活动信息 → 公开/上传状态 →
/// 照片入口(最近上传预览条 / 拍摄第一张照片引导)→ 次级管理入口(编辑/
/// 成员/分类/分享,ListTile 次强调)+ 分类管理区;底部固定「拍摄上传
/// (主)/ 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。
///
/// 数据范围:照片预览按 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「3-5 张缩略图」)。
const int _kPreviewStripLimit = 5;
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。
class AlbumDetailPage extends ConsumerStatefulWidget {
const AlbumDetailPage({required this.albumId, super.key});
final String albumId;
@override
ConsumerState<AlbumDetailPage> createState() => _AlbumDetailPageState();
}
class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
final GlobalKey _categoriesKey = GlobalKey();
final ScrollController _scrollController = ScrollController();
Album? _album;
bool _loading = true;
String? _errorCode;
/// 最近上传预览条(协作者为「自己上传」,服务端过滤)。
List<AdminPhoto> _recentPhotos = const <AdminPhoto>[];
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<void> _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<void> _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: _kPreviewStripLimit,
);
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 <AdminPhoto>[];
_photosLoading = false;
});
return;
}
try {
final ownPage = await api.listAlbumPhotos(
album.id,
scope: AlbumPhotoScope.own,
limit: _kPreviewStripLimit,
);
if (!mounted) return;
setState(() {
_recentPhotos = ownPage.items;
_photosLoading = false;
_isManager = false;
});
} on AppException {
if (!mounted) return;
setState(() {
_recentPhotos = const <AdminPhoto>[];
_photosLoading = false;
_isManager = false;
});
}
} on Object {
if (mounted) {
setState(() {
_recentPhotos = const <AdminPhoto>[];
_photosLoading = false;
});
}
}
}
/// 编辑成功后:详情重载 + 全局列表同步。
Future<void> _openEdit() async {
final album = _album;
if (album == null) return;
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => AlbumFormPage(existing: album),
),
);
if (changed == true) {
await _load();
ref.read(albumListProvider.notifier).refresh();
}
}
Future<void> _openMembers() async {
final album = _album;
if (album == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumMembersPage(album: album),
),
);
// 成员变更不影响详情数据,回来无需刷新。
}
Future<void> _openPhotos() async {
final album = _album;
if (album == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumPhotosPage(album: album),
),
);
// 管理页内可能发生隐藏/删除,回来后刷新预览条。
await _loadPhotoPreview();
}
void _shareQr() {
final album = _album;
if (album == null) return;
showDialog<void>(
context: context,
builder: (_) => ShareQrDialog(
publicToken: album.publicToken,
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
),
);
}
// ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ─────────
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
Future<String?> _currentAccountId() async {
final session = await ref.read(sessionStoreProvider).read();
return session?.account;
}
/// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。
Future<void> _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<void>(
builder: (_) => CapturePage(
target: CaptureTarget(
accountId: account,
albumId: album.id,
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
categories: album.categories,
),
),
),
);
}
/// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。
Future<void> _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<void> _deleteCategory(Category category) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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<void> _openCategoryDialog([Category? existing]) async {
final album = _album;
if (album == null) return;
final changed = await showDialog<bool>(
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)),
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,
padding: const EdgeInsets.all(kSpace16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_CompactHeader(album: album),
const SizedBox(height: kSpace16),
_StatusSection(album: album),
const SizedBox(height: kSpace16),
_PhotoEntrySection(
album: album,
photos: _recentPhotos,
loading: _photosLoading,
isManager: _isManager,
onOpenPhotos: _openPhotos,
onTakeFirst: _openCapture,
),
const SizedBox(height: kSpace16),
_SecondaryActions(
showManage: _isManager,
onEdit: _openEdit,
onMembers: _openMembers,
onCategories: _scrollToCategories,
onShareQr: _shareQr,
),
if (_isManager) ...[
const SizedBox(height: kSpace16),
_CategoriesSection(
key: _categoriesKey,
album: album,
onAdd: () => _openCategoryDialog(),
onEdit: (c) => _openCategoryDialog(c),
onDelete: _deleteCategory,
),
],
const SizedBox(height: kSpace32),
],
),
),
);
}
}
/// 紧凑头部:封面缩略图(16:9,最高 200)+ 标题 + 时间/场地单行。
class _CompactHeader extends StatelessWidget {
const _CompactHeader({required this.album});
final Album album;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
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);
final meta = venue == null
? '$start ~ $end (${album.timezone})'
: '$start ~ $end (${album.timezone}) · $venue';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LayoutBuilder(
builder: (context, constraints) {
// 16:9 封面,最高 200(超出按高度封顶,宽度同步收窄)。
final height = constraints.maxWidth * 9 / 16;
final capped = height > 200.0;
final h = capped ? 200.0 : height;
final w = capped ? h * 16 / 9 : constraints.maxWidth;
return Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(kRadiusCard),
child: SizedBox(
width: w,
height: h,
child: PhotoThumbnail(
url: album.cover?.url,
aspectRatio: w / h,
),
),
),
);
},
),
const SizedBox(height: kSpace12),
Text(
albumDisplayTitle(album, l10n.localeName),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: kSpace4),
Text(
meta,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
/// 状态区:公开 / 上传分别显示(关闭不等于不公开)。
class _StatusSection extends StatelessWidget {
const _StatusSection({required this.album});
final Album album;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Row(
children: [
StateBadge(
variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral,
label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff,
),
const SizedBox(width: kSpace8),
StateBadge(
variant:
album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral,
label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff,
),
if (album.status == AlbumStatus.offline) ...[
const SizedBox(width: kSpace8),
StateBadge(variant: BadgeVariant.error, label: l10n.albumStatusOffline),
],
],
);
}
}
/// 照片入口:有照片 → 预览条(≤5 张)+「管理照片」;无照片 →「拍摄第
/// 一张照片」引导(上传关闭时禁用,由底部栏说明原因)。
class _PhotoEntrySection extends StatelessWidget {
const _PhotoEntrySection({
required this.album,
required this.photos,
required this.loading,
required this.isManager,
required this.onOpenPhotos,
required this.onTakeFirst,
});
final Album album;
final List<AdminPhoto> photos;
final bool loading;
final bool isManager;
final VoidCallback onOpenPhotos;
final VoidCallback onTakeFirst;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SectionHeader(
title: l10n.albumPhotos,
trailing: TextButton(
onPressed: onOpenPhotos,
child: Text(l10n.albumManagePhotos),
),
),
const SizedBox(height: kSpace8),
if (loading)
const SizedBox(
height: 96,
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else if (photos.isNotEmpty)
SizedBox(
height: 96,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: photos.length,
separatorBuilder: (_, _) => const SizedBox(width: kSpace8),
itemBuilder: (context, index) {
final photo = photos[index];
return SizedBox(
width: 96,
height: 96,
child: PhotoThumbnail(
url: photo.preview?.thumb.url,
aspectRatio: 1,
),
);
},
),
)
else
Row(
children: [
Icon(
Icons.photo_camera_outlined,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: kSpace8),
Expanded(
child: Text(
l10n.albumPhotosEmpty,
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(width: kSpace8),
FilledButton.tonalIcon(
onPressed: album.uploadEnabled ? onTakeFirst : null,
icon: const Icon(Icons.photo_camera_outlined),
label: Text(l10n.albumTakeFirstPhoto),
),
],
),
],
);
}
}
/// 次级管理入口(ListTile 次强调,不再与主操作同级):编辑 / 成员 / 分类
/// (滚动定位)/ 分享二维码。管理类入口仅创建者/管理员可见;分享对全体
/// 成员开放(publicToken 本身对成员可见)。
class _SecondaryActions extends StatelessWidget {
const _SecondaryActions({
required this.showManage,
required this.onEdit,
required this.onMembers,
required this.onCategories,
required this.onShareQr,
});
final bool showManage;
final VoidCallback onEdit;
final VoidCallback onMembers;
final VoidCallback onCategories;
final VoidCallback onShareQr;
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Column(
children: [
if (showManage) ...[
ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(AppLocalizations.of(context).actionEdit),
onTap: onEdit,
),
ListTile(
leading: const Icon(Icons.group_outlined),
title: Text(AppLocalizations.of(context).albumMembers),
onTap: onMembers,
),
ListTile(
leading: const Icon(Icons.category_outlined),
title: Text(AppLocalizations.of(context).albumCategories),
onTap: onCategories,
),
],
ListTile(
leading: const Icon(Icons.qr_code_2),
title: Text(AppLocalizations.of(context).shareQrTitle),
onTap: onShareQr,
),
],
),
);
}
}
/// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传
/// 关闭时给出原因说明并禁用两个入口。
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<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 _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<void> 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<CategoryEditDialog> createState() => _CategoryEditDialogState();
}
class _CategoryEditDialogState extends ConsumerState<CategoryEditDialog> {
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<void> _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 = <CategoryTranslation>[
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),
),
],
);
}
}