diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index ef6f3f7..a122397 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -66,28 +66,40 @@ "albumsEmpty": "No albums yet. Tap \"Create album\" to get started", "loadMoreFailed": "Failed to load more. Tap to retry", "albumCreate": "Create album", - "albumEdit": "Edit album", - "albumSectionEventInfo": "Event info", - "albumSectionContent": "Multilingual content", - "albumSectionAccess": "Access settings", "albumTitle": "Title", "albumDescription": "Description", "albumVenue": "Venue", "albumAddress": "Address", - "albumDefaultLocale": "Default locale", "albumStartAt": "Start time", "albumEndAtOptional": "End time (optional)", "clearEndAt": "Clear end time", "albumTimezone": "Timezone", "albumTimezoneInvalid": "Invalid IANA timezone name", - "switchUpload": "Allow uploads", - "switchPublic": "Publicly visible", - "switchDownload": "Allow downloads", - "switchOriginalDownload": "Allow original downloads", "formStartAtRequired": "Pick a start time", "formDefaultLocaleMissing": "Default locale must have a title", "formTitleRequiredWithFields": "Title is required when description, venue or address is filled", "formEndAtNotAfterStartAt": "End time must be after start time", + "formEventDetails": "Event details", + "formDescriptionLanguages": "Description & languages", + "formDefaultLanguage": "Default language", + "formDetailsTab": "Details", + "formAccessTab": "Access", + "formAllowUploads": "Allow uploads", + "formAllowUploadsDesc": "Allow staff to upload photos to this album", + "formPublicAccess": "Public access", + "formPublicAccessDesc": "Anyone with the link can view photos", + "formAllowDownloads": "Allow downloads", + "formAllowDownloadsDesc": "Viewers can download browse-quality photos", + "formOriginalDownloads": "Original downloads", + "formOriginalDownloadsDesc": "Viewers can download original photos", + "createAlbumTitle": "Create album", + "createAlbumSubmit": "Create album", + "editAlbumTitle": "Edit album", + "saveChanges": "Save changes", + "unsavedChangesTitle": "Discard changes?", + "unsavedChangesBody": "Your edits are not saved yet. Leaving now will discard them.", + "discardChanges": "Discard", + "keepEditing": "Keep editing", "create": "Create", "save": "Save", "albumStatusActive": "Active", @@ -98,27 +110,36 @@ "tagOriginal": "Original", "notSet": "Not set", "actionEdit": "Edit", - "albumMembers": "Members", + "membersTitle": "Members", + "membersTitleCount": "Members ({count})", + "@membersTitleCount": { "placeholders": { "count": { "type": "int" } } }, + "ownerGroup": "Owner", + "collaboratorsGroup": "Collaborators", + "noCollaborators": "No collaborators yet", + "inviteColleague": "Invite colleague", + "alreadyAdded": "Already added", + "add": "Add", + "categoriesTitle": "Categories", + "newCategory": "New category", + "editCategory": "Edit category", + "deleteCategoryConfirm": "Delete \"{name}\"? Its photos will become uncategorized.", + "@deleteCategoryConfirm": { "placeholders": { "name": { "type": "String" } } }, + "noCategories": "No categories yet", "albumCategories": "Categories", "shareQrTitle": "Share QR code", "albumPhotos": "Photos", "delete": "Delete", - "deleteConfirmCategory": "Delete this category? Its photos will become uncategorized.", - "categoryEmpty": "No categories yet. Tap + to add one", - "categoryAdd": "Add category", - "categoryEdit": "Edit category", "categoryNameZh": "Chinese name", "categoryNameEn": "English name", "categorySortOrder": "Sort order", "categoryNameRequired": "At least one language name is required", "categorySortOrderInvalid": "Sort order must be an integer", - "albumMembersEmpty": "No members yet", "inviteSearchHint": "Search by account prefix to invite (at least 2 characters)", "inviteNoResults": "No matching users", - "invite": "Invite", "memberInvited": "Invited", "removeMember": "Remove member", - "removeMemberConfirm": "Remove this member?", + "removeMemberConfirm": "Remove {name} from this album?", + "@removeMemberConfirm": { "placeholders": { "name": { "type": "String" } } }, "roleOwner": "Owner", "roleCollaborator": "Collaborator", "selfMarker": " (you)", diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 48cfc53..adfbc1a 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -488,30 +488,6 @@ abstract class AppLocalizations { /// **'新建相册'** String get albumCreate; - /// No description provided for @albumEdit. - /// - /// In zh, this message translates to: - /// **'编辑相册'** - String get albumEdit; - - /// No description provided for @albumSectionEventInfo. - /// - /// In zh, this message translates to: - /// **'活动信息'** - String get albumSectionEventInfo; - - /// No description provided for @albumSectionContent. - /// - /// In zh, this message translates to: - /// **'多语言内容'** - String get albumSectionContent; - - /// No description provided for @albumSectionAccess. - /// - /// In zh, this message translates to: - /// **'访问设置'** - String get albumSectionAccess; - /// No description provided for @albumTitle. /// /// In zh, this message translates to: @@ -536,12 +512,6 @@ abstract class AppLocalizations { /// **'地址'** String get albumAddress; - /// No description provided for @albumDefaultLocale. - /// - /// In zh, this message translates to: - /// **'默认语言'** - String get albumDefaultLocale; - /// No description provided for @albumStartAt. /// /// In zh, this message translates to: @@ -572,30 +542,6 @@ abstract class AppLocalizations { /// **'无效的 IANA 时区名称'** String get albumTimezoneInvalid; - /// No description provided for @switchUpload. - /// - /// In zh, this message translates to: - /// **'允许上传'** - String get switchUpload; - - /// No description provided for @switchPublic. - /// - /// In zh, this message translates to: - /// **'公开可见'** - String get switchPublic; - - /// No description provided for @switchDownload. - /// - /// In zh, this message translates to: - /// **'允许下载'** - String get switchDownload; - - /// No description provided for @switchOriginalDownload. - /// - /// In zh, this message translates to: - /// **'允许下载原图'** - String get switchOriginalDownload; - /// No description provided for @formStartAtRequired. /// /// In zh, this message translates to: @@ -620,6 +566,132 @@ abstract class AppLocalizations { /// **'结束时间必须晚于开始时间'** String get formEndAtNotAfterStartAt; + /// No description provided for @formEventDetails. + /// + /// In zh, this message translates to: + /// **'活动信息'** + String get formEventDetails; + + /// No description provided for @formDescriptionLanguages. + /// + /// In zh, this message translates to: + /// **'描述与语言'** + String get formDescriptionLanguages; + + /// No description provided for @formDefaultLanguage. + /// + /// In zh, this message translates to: + /// **'默认语言'** + String get formDefaultLanguage; + + /// No description provided for @formDetailsTab. + /// + /// In zh, this message translates to: + /// **'详情'** + String get formDetailsTab; + + /// No description provided for @formAccessTab. + /// + /// In zh, this message translates to: + /// **'访问'** + String get formAccessTab; + + /// No description provided for @formAllowUploads. + /// + /// In zh, this message translates to: + /// **'允许上传'** + String get formAllowUploads; + + /// No description provided for @formAllowUploadsDesc. + /// + /// In zh, this message translates to: + /// **'允许工作人员向该相册上传照片'** + String get formAllowUploadsDesc; + + /// No description provided for @formPublicAccess. + /// + /// In zh, this message translates to: + /// **'公开访问'** + String get formPublicAccess; + + /// No description provided for @formPublicAccessDesc. + /// + /// In zh, this message translates to: + /// **'任何获得链接的人都可以查看照片'** + String get formPublicAccessDesc; + + /// No description provided for @formAllowDownloads. + /// + /// In zh, this message translates to: + /// **'允许下载'** + String get formAllowDownloads; + + /// No description provided for @formAllowDownloadsDesc. + /// + /// In zh, this message translates to: + /// **'观众可以下载浏览质量的照片'** + String get formAllowDownloadsDesc; + + /// No description provided for @formOriginalDownloads. + /// + /// In zh, this message translates to: + /// **'下载原图'** + String get formOriginalDownloads; + + /// No description provided for @formOriginalDownloadsDesc. + /// + /// In zh, this message translates to: + /// **'观众可以下载原图照片'** + String get formOriginalDownloadsDesc; + + /// No description provided for @createAlbumTitle. + /// + /// In zh, this message translates to: + /// **'新建相册'** + String get createAlbumTitle; + + /// No description provided for @createAlbumSubmit. + /// + /// In zh, this message translates to: + /// **'新建相册'** + String get createAlbumSubmit; + + /// No description provided for @editAlbumTitle. + /// + /// In zh, this message translates to: + /// **'编辑相册'** + String get editAlbumTitle; + + /// No description provided for @saveChanges. + /// + /// In zh, this message translates to: + /// **'保存修改'** + String get saveChanges; + + /// No description provided for @unsavedChangesTitle. + /// + /// In zh, this message translates to: + /// **'放弃修改?'** + String get unsavedChangesTitle; + + /// No description provided for @unsavedChangesBody. + /// + /// In zh, this message translates to: + /// **'修改尚未保存,离开将丢失这些修改。'** + String get unsavedChangesBody; + + /// No description provided for @discardChanges. + /// + /// In zh, this message translates to: + /// **'放弃'** + String get discardChanges; + + /// No description provided for @keepEditing. + /// + /// In zh, this message translates to: + /// **'继续编辑'** + String get keepEditing; + /// No description provided for @create. /// /// In zh, this message translates to: @@ -680,11 +752,83 @@ abstract class AppLocalizations { /// **'编辑'** String get actionEdit; - /// No description provided for @albumMembers. + /// No description provided for @membersTitle. /// /// In zh, this message translates to: - /// **'成员管理'** - String get albumMembers; + /// **'成员'** + String get membersTitle; + + /// No description provided for @membersTitleCount. + /// + /// In zh, this message translates to: + /// **'成员({count})'** + String membersTitleCount(int count); + + /// No description provided for @ownerGroup. + /// + /// In zh, this message translates to: + /// **'创建者'** + String get ownerGroup; + + /// No description provided for @collaboratorsGroup. + /// + /// In zh, this message translates to: + /// **'协作者'** + String get collaboratorsGroup; + + /// No description provided for @noCollaborators. + /// + /// In zh, this message translates to: + /// **'还没有协作者'** + String get noCollaborators; + + /// No description provided for @inviteColleague. + /// + /// In zh, this message translates to: + /// **'邀请同事'** + String get inviteColleague; + + /// No description provided for @alreadyAdded. + /// + /// In zh, this message translates to: + /// **'已添加'** + String get alreadyAdded; + + /// No description provided for @add. + /// + /// In zh, this message translates to: + /// **'添加'** + String get add; + + /// No description provided for @categoriesTitle. + /// + /// In zh, this message translates to: + /// **'分类'** + String get categoriesTitle; + + /// No description provided for @newCategory. + /// + /// In zh, this message translates to: + /// **'新增分类'** + String get newCategory; + + /// No description provided for @editCategory. + /// + /// In zh, this message translates to: + /// **'编辑分类'** + String get editCategory; + + /// No description provided for @deleteCategoryConfirm. + /// + /// In zh, this message translates to: + /// **'删除「{name}」?该分类下的照片将变为未分类。'** + String deleteCategoryConfirm(String name); + + /// No description provided for @noCategories. + /// + /// In zh, this message translates to: + /// **'暂无分类'** + String get noCategories; /// No description provided for @albumCategories. /// @@ -710,30 +854,6 @@ abstract class AppLocalizations { /// **'删除'** String get delete; - /// No description provided for @deleteConfirmCategory. - /// - /// In zh, this message translates to: - /// **'确定删除该分类?该分类下的照片将变为未分类。'** - String get deleteConfirmCategory; - - /// No description provided for @categoryEmpty. - /// - /// In zh, this message translates to: - /// **'暂无分类,点击右上角新增'** - String get categoryEmpty; - - /// No description provided for @categoryAdd. - /// - /// In zh, this message translates to: - /// **'新增分类'** - String get categoryAdd; - - /// No description provided for @categoryEdit. - /// - /// In zh, this message translates to: - /// **'编辑分类'** - String get categoryEdit; - /// No description provided for @categoryNameZh. /// /// In zh, this message translates to: @@ -764,12 +884,6 @@ abstract class AppLocalizations { /// **'排序序号必须是整数'** String get categorySortOrderInvalid; - /// No description provided for @albumMembersEmpty. - /// - /// In zh, this message translates to: - /// **'暂无成员'** - String get albumMembersEmpty; - /// No description provided for @inviteSearchHint. /// /// In zh, this message translates to: @@ -782,12 +896,6 @@ abstract class AppLocalizations { /// **'未找到匹配用户'** String get inviteNoResults; - /// No description provided for @invite. - /// - /// In zh, this message translates to: - /// **'邀请'** - String get invite; - /// No description provided for @memberInvited. /// /// In zh, this message translates to: @@ -803,8 +911,8 @@ abstract class AppLocalizations { /// No description provided for @removeMemberConfirm. /// /// In zh, this message translates to: - /// **'确定移除该成员?'** - String get removeMemberConfirm; + /// **'确定将 {name} 从该相册移除?'** + String removeMemberConfirm(String name); /// No description provided for @roleOwner. /// diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index eb12d62..335e8ee 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -211,18 +211,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get albumCreate => 'Create album'; - @override - String get albumEdit => 'Edit album'; - - @override - String get albumSectionEventInfo => 'Event info'; - - @override - String get albumSectionContent => 'Multilingual content'; - - @override - String get albumSectionAccess => 'Access settings'; - @override String get albumTitle => 'Title'; @@ -235,9 +223,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get albumAddress => 'Address'; - @override - String get albumDefaultLocale => 'Default locale'; - @override String get albumStartAt => 'Start time'; @@ -253,18 +238,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get albumTimezoneInvalid => 'Invalid IANA timezone name'; - @override - String get switchUpload => 'Allow uploads'; - - @override - String get switchPublic => 'Publicly visible'; - - @override - String get switchDownload => 'Allow downloads'; - - @override - String get switchOriginalDownload => 'Allow original downloads'; - @override String get formStartAtRequired => 'Pick a start time'; @@ -278,6 +251,73 @@ class AppLocalizationsEn extends AppLocalizations { @override String get formEndAtNotAfterStartAt => 'End time must be after start time'; + @override + String get formEventDetails => 'Event details'; + + @override + String get formDescriptionLanguages => 'Description & languages'; + + @override + String get formDefaultLanguage => 'Default language'; + + @override + String get formDetailsTab => 'Details'; + + @override + String get formAccessTab => 'Access'; + + @override + String get formAllowUploads => 'Allow uploads'; + + @override + String get formAllowUploadsDesc => + 'Allow staff to upload photos to this album'; + + @override + String get formPublicAccess => 'Public access'; + + @override + String get formPublicAccessDesc => 'Anyone with the link can view photos'; + + @override + String get formAllowDownloads => 'Allow downloads'; + + @override + String get formAllowDownloadsDesc => + 'Viewers can download browse-quality photos'; + + @override + String get formOriginalDownloads => 'Original downloads'; + + @override + String get formOriginalDownloadsDesc => + 'Viewers can download original photos'; + + @override + String get createAlbumTitle => 'Create album'; + + @override + String get createAlbumSubmit => 'Create album'; + + @override + String get editAlbumTitle => 'Edit album'; + + @override + String get saveChanges => 'Save changes'; + + @override + String get unsavedChangesTitle => 'Discard changes?'; + + @override + String get unsavedChangesBody => + 'Your edits are not saved yet. Leaving now will discard them.'; + + @override + String get discardChanges => 'Discard'; + + @override + String get keepEditing => 'Keep editing'; + @override String get create => 'Create'; @@ -309,7 +349,47 @@ class AppLocalizationsEn extends AppLocalizations { String get actionEdit => 'Edit'; @override - String get albumMembers => 'Members'; + String get membersTitle => 'Members'; + + @override + String membersTitleCount(int count) { + return 'Members ($count)'; + } + + @override + String get ownerGroup => 'Owner'; + + @override + String get collaboratorsGroup => 'Collaborators'; + + @override + String get noCollaborators => 'No collaborators yet'; + + @override + String get inviteColleague => 'Invite colleague'; + + @override + String get alreadyAdded => 'Already added'; + + @override + String get add => 'Add'; + + @override + String get categoriesTitle => 'Categories'; + + @override + String get newCategory => 'New category'; + + @override + String get editCategory => 'Edit category'; + + @override + String deleteCategoryConfirm(String name) { + return 'Delete \"$name\"? Its photos will become uncategorized.'; + } + + @override + String get noCategories => 'No categories yet'; @override String get albumCategories => 'Categories'; @@ -323,19 +403,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get delete => 'Delete'; - @override - String get deleteConfirmCategory => - 'Delete this category? Its photos will become uncategorized.'; - - @override - String get categoryEmpty => 'No categories yet. Tap + to add one'; - - @override - String get categoryAdd => 'Add category'; - - @override - String get categoryEdit => 'Edit category'; - @override String get categoryNameZh => 'Chinese name'; @@ -351,9 +418,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get categorySortOrderInvalid => 'Sort order must be an integer'; - @override - String get albumMembersEmpty => 'No members yet'; - @override String get inviteSearchHint => 'Search by account prefix to invite (at least 2 characters)'; @@ -361,9 +425,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get inviteNoResults => 'No matching users'; - @override - String get invite => 'Invite'; - @override String get memberInvited => 'Invited'; @@ -371,7 +432,9 @@ class AppLocalizationsEn extends AppLocalizations { String get removeMember => 'Remove member'; @override - String get removeMemberConfirm => 'Remove this member?'; + String removeMemberConfirm(String name) { + return 'Remove $name from this album?'; + } @override String get roleOwner => 'Owner'; diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 4bf6b1c..2a3a59a 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -206,18 +206,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get albumCreate => '新建相册'; - @override - String get albumEdit => '编辑相册'; - - @override - String get albumSectionEventInfo => '活动信息'; - - @override - String get albumSectionContent => '多语言内容'; - - @override - String get albumSectionAccess => '访问设置'; - @override String get albumTitle => '标题'; @@ -230,9 +218,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get albumAddress => '地址'; - @override - String get albumDefaultLocale => '默认语言'; - @override String get albumStartAt => '开始时间'; @@ -248,18 +233,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get albumTimezoneInvalid => '无效的 IANA 时区名称'; - @override - String get switchUpload => '允许上传'; - - @override - String get switchPublic => '公开可见'; - - @override - String get switchDownload => '允许下载'; - - @override - String get switchOriginalDownload => '允许下载原图'; - @override String get formStartAtRequired => '请选择开始时间'; @@ -272,6 +245,69 @@ class AppLocalizationsZh extends AppLocalizations { @override String get formEndAtNotAfterStartAt => '结束时间必须晚于开始时间'; + @override + String get formEventDetails => '活动信息'; + + @override + String get formDescriptionLanguages => '描述与语言'; + + @override + String get formDefaultLanguage => '默认语言'; + + @override + String get formDetailsTab => '详情'; + + @override + String get formAccessTab => '访问'; + + @override + String get formAllowUploads => '允许上传'; + + @override + String get formAllowUploadsDesc => '允许工作人员向该相册上传照片'; + + @override + String get formPublicAccess => '公开访问'; + + @override + String get formPublicAccessDesc => '任何获得链接的人都可以查看照片'; + + @override + String get formAllowDownloads => '允许下载'; + + @override + String get formAllowDownloadsDesc => '观众可以下载浏览质量的照片'; + + @override + String get formOriginalDownloads => '下载原图'; + + @override + String get formOriginalDownloadsDesc => '观众可以下载原图照片'; + + @override + String get createAlbumTitle => '新建相册'; + + @override + String get createAlbumSubmit => '新建相册'; + + @override + String get editAlbumTitle => '编辑相册'; + + @override + String get saveChanges => '保存修改'; + + @override + String get unsavedChangesTitle => '放弃修改?'; + + @override + String get unsavedChangesBody => '修改尚未保存,离开将丢失这些修改。'; + + @override + String get discardChanges => '放弃'; + + @override + String get keepEditing => '继续编辑'; + @override String get create => '创建'; @@ -303,7 +339,47 @@ class AppLocalizationsZh extends AppLocalizations { String get actionEdit => '编辑'; @override - String get albumMembers => '成员管理'; + String get membersTitle => '成员'; + + @override + String membersTitleCount(int count) { + return '成员($count)'; + } + + @override + String get ownerGroup => '创建者'; + + @override + String get collaboratorsGroup => '协作者'; + + @override + String get noCollaborators => '还没有协作者'; + + @override + String get inviteColleague => '邀请同事'; + + @override + String get alreadyAdded => '已添加'; + + @override + String get add => '添加'; + + @override + String get categoriesTitle => '分类'; + + @override + String get newCategory => '新增分类'; + + @override + String get editCategory => '编辑分类'; + + @override + String deleteCategoryConfirm(String name) { + return '删除「$name」?该分类下的照片将变为未分类。'; + } + + @override + String get noCategories => '暂无分类'; @override String get albumCategories => '分类管理'; @@ -317,18 +393,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get delete => '删除'; - @override - String get deleteConfirmCategory => '确定删除该分类?该分类下的照片将变为未分类。'; - - @override - String get categoryEmpty => '暂无分类,点击右上角新增'; - - @override - String get categoryAdd => '新增分类'; - - @override - String get categoryEdit => '编辑分类'; - @override String get categoryNameZh => '中文名称'; @@ -344,18 +408,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get categorySortOrderInvalid => '排序序号必须是整数'; - @override - String get albumMembersEmpty => '暂无成员'; - @override String get inviteSearchHint => '输入账号前缀搜索邀请(至少 2 个字符)'; @override String get inviteNoResults => '未找到匹配用户'; - @override - String get invite => '邀请'; - @override String get memberInvited => '已邀请'; @@ -363,7 +421,9 @@ class AppLocalizationsZh extends AppLocalizations { String get removeMember => '移除成员'; @override - String get removeMemberConfirm => '确定移除该成员?'; + String removeMemberConfirm(String name) { + return '确定将 $name 从该相册移除?'; + } @override String get roleOwner => '创建者'; diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index e47109d..f2d1c2c 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -66,28 +66,40 @@ "albumsEmpty": "暂无相册,点击「新建相册」开始", "loadMoreFailed": "加载更多失败,点击重试", "albumCreate": "新建相册", - "albumEdit": "编辑相册", - "albumSectionEventInfo": "活动信息", - "albumSectionContent": "多语言内容", - "albumSectionAccess": "访问设置", "albumTitle": "标题", "albumDescription": "描述", "albumVenue": "场地", "albumAddress": "地址", - "albumDefaultLocale": "默认语言", "albumStartAt": "开始时间", "albumEndAtOptional": "结束时间(可选)", "clearEndAt": "清除结束时间", "albumTimezone": "时区", "albumTimezoneInvalid": "无效的 IANA 时区名称", - "switchUpload": "允许上传", - "switchPublic": "公开可见", - "switchDownload": "允许下载", - "switchOriginalDownload": "允许下载原图", "formStartAtRequired": "请选择开始时间", "formDefaultLocaleMissing": "默认语言必须填写标题", "formTitleRequiredWithFields": "填写了描述/场地/地址时,标题不能为空", "formEndAtNotAfterStartAt": "结束时间必须晚于开始时间", + "formEventDetails": "活动信息", + "formDescriptionLanguages": "描述与语言", + "formDefaultLanguage": "默认语言", + "formDetailsTab": "详情", + "formAccessTab": "访问", + "formAllowUploads": "允许上传", + "formAllowUploadsDesc": "允许工作人员向该相册上传照片", + "formPublicAccess": "公开访问", + "formPublicAccessDesc": "任何获得链接的人都可以查看照片", + "formAllowDownloads": "允许下载", + "formAllowDownloadsDesc": "观众可以下载浏览质量的照片", + "formOriginalDownloads": "下载原图", + "formOriginalDownloadsDesc": "观众可以下载原图照片", + "createAlbumTitle": "新建相册", + "createAlbumSubmit": "新建相册", + "editAlbumTitle": "编辑相册", + "saveChanges": "保存修改", + "unsavedChangesTitle": "放弃修改?", + "unsavedChangesBody": "修改尚未保存,离开将丢失这些修改。", + "discardChanges": "放弃", + "keepEditing": "继续编辑", "create": "创建", "save": "保存", "albumStatusActive": "正常", @@ -98,27 +110,36 @@ "tagOriginal": "原图", "notSet": "未定", "actionEdit": "编辑", - "albumMembers": "成员管理", + "membersTitle": "成员", + "membersTitleCount": "成员({count})", + "@membersTitleCount": { "placeholders": { "count": { "type": "int" } } }, + "ownerGroup": "创建者", + "collaboratorsGroup": "协作者", + "noCollaborators": "还没有协作者", + "inviteColleague": "邀请同事", + "alreadyAdded": "已添加", + "add": "添加", + "categoriesTitle": "分类", + "newCategory": "新增分类", + "editCategory": "编辑分类", + "deleteCategoryConfirm": "删除「{name}」?该分类下的照片将变为未分类。", + "@deleteCategoryConfirm": { "placeholders": { "name": { "type": "String" } } }, + "noCategories": "暂无分类", "albumCategories": "分类管理", "shareQrTitle": "分享二维码", "albumPhotos": "照片管理", "delete": "删除", - "deleteConfirmCategory": "确定删除该分类?该分类下的照片将变为未分类。", - "categoryEmpty": "暂无分类,点击右上角新增", - "categoryAdd": "新增分类", - "categoryEdit": "编辑分类", "categoryNameZh": "中文名称", "categoryNameEn": "英文名称", "categorySortOrder": "排序序号", "categoryNameRequired": "至少填写一个语言的名称", "categorySortOrderInvalid": "排序序号必须是整数", - "albumMembersEmpty": "暂无成员", "inviteSearchHint": "输入账号前缀搜索邀请(至少 2 个字符)", "inviteNoResults": "未找到匹配用户", - "invite": "邀请", "memberInvited": "已邀请", "removeMember": "移除成员", - "removeMemberConfirm": "确定移除该成员?", + "removeMemberConfirm": "确定将 {name} 从该相册移除?", + "@removeMemberConfirm": { "placeholders": { "name": { "type": "String" } } }, "roleOwner": "创建者", "roleCollaborator": "协作者", "selfMarker": "(我)", diff --git a/apps/mobile/lib/pages/album_categories_page.dart b/apps/mobile/lib/pages/album_categories_page.dart new file mode 100644 index 0000000..a7c6dd0 --- /dev/null +++ b/apps/mobile/lib/pages/album_categories_page.dart @@ -0,0 +1,466 @@ +/// 相册分类独立管理页(UI-03 §4 页面 12,自详情页分类区块提取): +/// 列表(默认语言名 + 排序序号 + 行尾「⋯」编辑/删除菜单)+ 底部全宽 +/// 「New category」。新增/编辑走 BottomSheet(语言页签 + 名称 + 排序 +/// 序号),删除确认说明照片影响(变为未分类)。无权限用户(协作者) +/// 仅浏览,不显示管理入口。CRUD 后经 GET /albums/{id} 刷新,避免与详情 +/// 页两份状态不同步。 +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_l10n.dart'; +import '../features/albums/models.dart'; +import '../l10n/app_localizations.dart'; +import '../ui/tokens.dart'; + +/// 分类管理页([album] 携带完整相册与初始分类;[isManager] 控制管理入口)。 +class AlbumCategoriesPage extends ConsumerStatefulWidget { + const AlbumCategoriesPage({required this.album, super.key, this.isManager = true}); + + final Album album; + + /// 是否管理者(owner/admin):false 时仅浏览(无「⋯」与新增按钮)。 + final bool isManager; + + @override + ConsumerState createState() => + _AlbumCategoriesPageState(); +} + +class _AlbumCategoriesPageState extends ConsumerState { + late Album _album; + bool _reloading = false; + + @override + void initState() { + super.initState(); + _album = widget.album; + } + + /// CRUD 后重取相册(categories 随详情返回),保持单一数据源。 + Future _reload() async { + setState(() => _reloading = true); + try { + final album = await ref.read(apiClientProvider).getAlbum(_album.id); + if (mounted) setState(() => _album = album); + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).messageForError(e))), + ); + } + } on Object { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).errorGeneric)), + ); + } + } finally { + if (mounted) setState(() => _reloading = false); + } + } + + // ── 新增/编辑(BottomSheet:语言页签 + 名称 + 排序序号) ────────── + + Future _openEditor([Category? existing]) async { + final changed = await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => CategoryEditSheet( + albumId: _album.id, + defaultLocale: _album.defaultLocale, + existing: existing, + ), + ); + if (changed == true) await _reload(); + } + + // ── 删除(确认说明照片影响) ──────────────────────────────────── + + Future _delete(Category category) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(l10n.delete), + content: Text( + l10n.deleteCategoryConfirm( + categoryDisplayName(category, _album.defaultLocale), + ), + ), + 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 _reload(); + } on AppException catch (e) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.messageForError(e))), + ); + } on Object { + messenger.showSnackBar( + SnackBar(content: Text(l10n.errorGeneric)), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final categories = _album.categories; + return Scaffold( + appBar: AppBar(title: Text(l10n.categoriesTitle)), + body: _reloading && categories.isEmpty + ? const Center(child: CircularProgressIndicator()) + : categories.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.category_outlined, + size: 48, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: kSpace12), + Text( + l10n.noCategories, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ) + : ListView( + padding: const EdgeInsets.all(kSpace16), + children: [ + Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (final (i, category) in categories.indexed) ...[ + if (i > 0) + const Divider(height: 1, indent: kSpace16, endIndent: kSpace16), + ListTile( + title: Text( + categoryDisplayName( + category, + _album.defaultLocale, + ), + ), + trailing: widget.isManager + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${category.sortOrder}', + style: + theme.textTheme.labelMedium?.copyWith( + color: theme + .colorScheme.onSurfaceVariant, + ), + ), + PopupMenuButton( + icon: const Icon(Icons.more_horiz), + onSelected: (action) => action == 'edit' + ? _openEditor(category) + : _delete(category), + itemBuilder: (_) => [ + PopupMenuItem( + value: 'edit', + child: Text(l10n.editCategory), + ), + PopupMenuItem( + value: 'delete', + child: Text(l10n.delete), + ), + ], + ), + ], + ) + : Text( + '${category.sortOrder}', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ], + ), + ), + ], + ), + // 管理者:全宽「New category」;协作者仅浏览。 + bottomNavigationBar: widget.isManager + ? SafeArea( + minimum: const EdgeInsets.fromLTRB( + kSpace16, kSpace8, kSpace16, kSpace12, + ), + child: FilledButton.icon( + onPressed: _reloading ? null : () => _openEditor(), + icon: const Icon(Icons.add), + label: Text(l10n.newCategory), + ), + ) + : null, + ); + } +} + +/// 分类展示名:优先相册默认语言条目,回退任一非空名称,再回退分类 ID。 +String categoryDisplayName(Category category, String locale) { + for (final t in category.translations) { + if (t.locale == locale && t.name.trim().isNotEmpty) return t.name.trim(); + } + for (final t in category.translations) { + if (t.name.trim().isNotEmpty) return t.name.trim(); + } + return category.id; +} + +/// 新增/编辑分类 BottomSheet:语言页签(中文/English,各自一份名称)+ +/// 排序序号;至少一个语言名称必填。成功 pop(true);校验失败就地提示; +/// API 失败 SnackBar(面板保持,输入不丢)。 +class CategoryEditSheet extends ConsumerStatefulWidget { + const CategoryEditSheet({ + required this.albumId, + required this.defaultLocale, + this.existing, + super.key, + }); + + final String albumId; + + /// 相册默认语言(决定初始页签)。 + final String defaultLocale; + + /// 编辑目标;null = 新增。 + final Category? existing; + + @override + ConsumerState createState() => _CategoryEditSheetState(); +} + +class _CategoryEditSheetState extends ConsumerState + with SingleTickerProviderStateMixin { + static const String _zh = 'zh-CN'; + + late final TabController _tabs; + late final TextEditingController _sortOrder; + late final TextEditingController _zhName; + late final TextEditingController _enName; + + String? _error; + bool _submitting = false; + int _tabIndex = 0; + + @override + void initState() { + super.initState(); + _tabs = TabController( + length: 2, + vsync: this, + initialIndex: widget.defaultLocale == _zh ? 0 : 1, + ); + _tabIndex = _tabs.index; + _sortOrder = TextEditingController( + text: (widget.existing?.sortOrder ?? 0).toString(), + ); + _zhName = TextEditingController( + text: widget.existing?.translations + .where((t) => t.locale == _zh) + .firstOrNull + ?.name ?? + '', + ); + _enName = TextEditingController( + text: widget.existing?.translations + .where((t) => t.locale == 'en') + .firstOrNull + ?.name ?? + '', + ); + } + + @override + void dispose() { + _tabs.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, 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); + final isCreate = widget.existing == null; + return Padding( + // 键盘避让:内容随视口内边距上移,保存按钮常驻可达。 + padding: EdgeInsets.fromLTRB( + kSpace16, + 0, + kSpace16, + MediaQuery.of(context).viewInsets.bottom + kSpace16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + isCreate ? l10n.newCategory : l10n.editCategory, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: kSpace12), + TabBar( + controller: _tabs, + onTap: (index) => setState(() => _tabIndex = index), + tabs: [ + Tab(text: l10n.localeChinese), + Tab(text: l10n.localeEnglish), + ], + ), + const SizedBox(height: kSpace12), + if (_tabIndex == 0) + TextFormField( + controller: _zhName, + enabled: !_submitting, + decoration: InputDecoration( + labelText: l10n.categoryNameZh, + border: const OutlineInputBorder(), + ), + ) + else + TextFormField( + controller: _enName, + enabled: !_submitting, + decoration: InputDecoration( + labelText: l10n.categoryNameEn, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: kSpace12), + TextFormField( + controller: _sortOrder, + enabled: !_submitting, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: l10n.categorySortOrder, + border: const OutlineInputBorder(), + ), + ), + if (_error != null) ...[ + const SizedBox(height: kSpace8), + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + const SizedBox(height: kSpace16), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _submitting + ? null + : () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + ), + const SizedBox(width: kSpace12), + Expanded( + child: FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.save), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index ba85122..5fdd5ee 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -2,8 +2,9 @@ /// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再 /// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口 /// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览 -/// (≤9 张)+ See all →(管理者)分类管理区;底部固定「拍摄上传(主)/ -/// 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。 +/// (≤9 张)+ See all;分类自页面 12 起提取为独立管理页(本页仅导航); +/// 底部固定「拍摄上传(主)/从相册选择(次)」,避让安全区,上传关闭时 +/// 说明原因并禁用。 /// /// 空相册(页面 08):保留真实封面与资料,中心安静占位「Ready for your /// first photo」;协作者显示只读说明;有处理中照片时显示「处理中」,不 @@ -31,6 +32,7 @@ import '../ui/components/photo_thumbnail.dart'; import '../ui/components/section_header.dart'; import '../ui/components/state_badge.dart'; import '../ui/tokens.dart'; +import 'album_categories_page.dart'; import 'album_form_page.dart'; import 'album_members_page.dart'; import 'album_photos_page.dart'; @@ -54,9 +56,6 @@ class AlbumDetailPage extends ConsumerStatefulWidget { } class _AlbumDetailPageState extends ConsumerState { - final GlobalKey _categoriesKey = GlobalKey(); - final ScrollController _scrollController = ScrollController(); - Album? _album; bool _loading = true; String? _errorCode; @@ -75,12 +74,6 @@ class _AlbumDetailPageState extends ConsumerState { _load(); } - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - Future _load() async { setState(() { _loading = true; @@ -210,6 +203,19 @@ class _AlbumDetailPageState extends ConsumerState { await _loadPhotoPreview(); } + /// 分类管理(页面 12):进入独立分类页;返回后重载详情(分类随详情 + /// 返回,供连拍目标等使用)。 + Future _openCategories() async { + final album = _album; + if (album == null) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AlbumCategoriesPage(album: album, isManager: _isManager), + ), + ); + await _load(); + } + void _shareQr() { final album = _album; if (album == null) return; @@ -237,7 +243,7 @@ class _AlbumDetailPageState extends ConsumerState { case AlbumAction.members: await _openMembers(); case AlbumAction.categories: - _scrollToCategories(); + await _openCategories(); case AlbumAction.share: _shareQr(); } @@ -294,64 +300,6 @@ class _AlbumDetailPageState extends ConsumerState { ); } - 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); @@ -381,7 +329,6 @@ class _AlbumDetailPageState extends ConsumerState { onRetry: _load, ) : SingleChildScrollView( - controller: _scrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -423,7 +370,7 @@ class _AlbumDetailPageState extends ConsumerState { isManager: _isManager, onPhotos: _openPhotos, onMembers: _openMembers, - onCategories: _scrollToCategories, + onCategories: _openCategories, onShare: _shareQr, ), ), @@ -441,21 +388,6 @@ class _AlbumDetailPageState extends ConsumerState { 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), ], ), @@ -911,257 +843,3 @@ class _ErrorRetryView extends StatelessWidget { } } -/// 分类管理区:列表(点击编辑、尾随删除)+ 新增入口。 -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), - ), - ], - ); - } -} diff --git a/apps/mobile/lib/pages/album_form_page.dart b/apps/mobile/lib/pages/album_form_page.dart index 8a7f025..8bf4e87 100644 --- a/apps/mobile/lib/pages/album_form_page.dart +++ b/apps/mobile/lib/pages/album_form_page.dart @@ -1,12 +1,19 @@ -/// 相册创建/编辑表单页(UI-01 §4.5 分组重构,P3 计划 Task 4 基础)。 +/// 相册创建/编辑表单页(UI-03 §4 页面 09-10 重设计)。 +/// +/// 创建(页面 09):两分组(SectionHeader)——「Event details」(开始/ +/// 结束时间 + 显式可见时区字段,缺省 Asia/Shanghai,不静默取设备时区)+ +/// 「Description & languages」(默认语言必选 + zh-CN·en 双语 tab:标题/ +/// 描述/场地/地址按语言各一份——契约 translations 的场地/地址是逐语言字 +/// 段,不合并为共享输入);无 Access 页签(开关取契约缺省)。 +/// +/// 编辑(页面 10):顶部 Details / Access 页签共享同一份草稿——Details +/// 复用创建字段;Access 为四开关(upload/public/download/original)各配 +/// 说明,原图下载受总下载开关约束(关闭时禁用且生效值为 false);切页签 +/// 不丢草稿、不写服务器。保存按钮固定 bottomNavigationBar——键盘打开时 +/// 常驻可达;未保存离开经 PopScope 放弃确认。 /// -/// 三分组(SectionHeader):活动信息(开始/结束时间 + 时区)/ 多语言 -/// 内容(zh-CN · en 双语 tab:标题/描述/场地/地址按语言各一份——契约 -/// translations 的场地/地址是逐语言字段,不合并为共享输入)/ 访问设置 -/// (默认语言 + 开关组:创建仅 download/original,编辑四开关独立)。 -/// 保存按钮固定在 bottomNavigationBar——键盘打开时常驻可达(§4.5)。 /// 编辑提交经 [AlbumFormDraft.toUpdatePatch] 最小化 PATCH(仅变更字段); -/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情。 +/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情;失败保留输入。 library; import 'package:flutter/material.dart'; @@ -36,7 +43,8 @@ class AlbumFormPage extends ConsumerStatefulWidget { ConsumerState createState() => _AlbumFormPageState(); } -class _AlbumFormPageState extends ConsumerState { +class _AlbumFormPageState extends ConsumerState + with TickerProviderStateMixin { static const String _zh = 'zh-CN'; static const String _en = 'en'; @@ -60,7 +68,17 @@ class _AlbumFormPageState extends ConsumerState { late bool _downloadEnabled; late bool _originalDownloadEnabled; - int _tabIndex = 0; + /// 进入时的初始草稿快照(未保存变更判定基准)。 + late final AlbumFormDraft _initial; + + /// 语言 tab(Details 内 zh-CN/en 字段切换)。 + late final TabController _langTabs; + int _langIndex = 0; + + /// Details/Access 主页签(仅编辑模式创建;创建模式无 Access)。 + TabController? _mainTabs; + int _mainIndex = 0; + List _errors = const []; bool _submitting = false; @@ -78,6 +96,7 @@ class _AlbumFormPageState extends ConsumerState { timezone: 'Asia/Shanghai', )) : (widget.initialDraft ?? AlbumFormDraft.fromAlbum(widget.existing!)); + _initial = initial; _zhTitle = TextEditingController(text: initial.zh.title); _zhDescription = TextEditingController(text: initial.zh.description); _zhVenue = TextEditingController(text: initial.zh.venue); @@ -105,10 +124,19 @@ class _AlbumFormPageState extends ConsumerState { _publicEnabled = initial.publicEnabled; _downloadEnabled = initial.downloadEnabled; _originalDownloadEnabled = initial.originalDownloadEnabled; + + _langTabs = TabController(length: 2, vsync: this); + // 仅编辑模式有主页签;内容区按 _mainIndex 手动切换(非 TabBarView), + // 页签往返不重建字段——草稿共享、焦点不丢。 + if (!_isCreate) { + _mainTabs = TabController(length: 2, vsync: this); + } } @override void dispose() { + _langTabs.dispose(); + _mainTabs?.dispose(); for (final c in _controllers) { c.dispose(); } @@ -137,9 +165,36 @@ class _AlbumFormPageState extends ConsumerState { uploadEnabled: _uploadEnabled, publicEnabled: _publicEnabled, downloadEnabled: _downloadEnabled, - originalDownloadEnabled: _originalDownloadEnabled, + // 原图下载受总下载开关约束:下载关闭时生效值恒为 false。 + originalDownloadEnabled: _downloadEnabled && _originalDownloadEnabled, ); + /// 未保存变更判定:编辑 = 与原相册有差异(PATCH 非空);创建 = 与初始 + /// 草稿有任何字段差异(用户实际输入过内容才算脏)。 + bool get _isDirty { + if (_isCreate) { + final current = _currentDraft(); + final init = _initial; + return current.zh.title != init.zh.title || + current.zh.description != init.zh.description || + current.zh.venue != init.zh.venue || + current.zh.address != init.zh.address || + current.en.title != init.en.title || + current.en.description != init.en.description || + current.en.venue != init.en.venue || + current.en.address != init.en.address || + current.defaultLocale != init.defaultLocale || + current.timezone != init.timezone || + current.startAt != init.startAt || + current.endAt != init.endAt || + current.uploadEnabled != init.uploadEnabled || + current.publicEnabled != init.publicEnabled || + current.downloadEnabled != init.downloadEnabled || + current.originalDownloadEnabled != init.originalDownloadEnabled; + } + return _currentDraft().toUpdatePatch(widget.existing!) != null; + } + /// 编辑模式:与原值无任何差异时禁用保存(PATCH minProperties 1)。 bool get _canSubmit { if (_submitting) return false; @@ -151,7 +206,15 @@ class _AlbumFormPageState extends ConsumerState { final draft = _currentDraft(); final errors = draft.validate(); if (errors.isNotEmpty) { - setState(() => _errors = errors); + // Details 字段校验失败:跳回 Details 页签展示错误(Access 页签无 + // 可校验字段)。 + setState(() { + _errors = errors; + if (_mainTabs != null && _mainIndex != 0) { + _mainTabs!.index = 0; + _mainIndex = 0; + } + }); return; } setState(() { @@ -176,6 +239,7 @@ class _AlbumFormPageState extends ConsumerState { } navigator.pop(true); } on AppException catch (error) { + // 保存失败:保留输入,仅提示。 messenger.showSnackBar( SnackBar(content: Text(l10n.messageForError(error))), ); @@ -186,6 +250,31 @@ class _AlbumFormPageState extends ConsumerState { } } + /// 未保存变更 → 放弃确认;确认后直接 pop(绕过 PopScope 拦截)。 + Future _confirmDiscard() async { + final l10n = AppLocalizations.of(context); + final discard = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(l10n.unsavedChangesTitle), + content: Text(l10n.unsavedChangesBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.keepEditing), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.discardChanges), + ), + ], + ), + ); + if (discard == true && mounted) { + Navigator.of(context).pop(); + } + } + /// 日期 + 时间选择器 → 相册时区墙钟 → UTC 瞬间。 Future _pickDateTime({required bool isStart}) async { final tzName = _timezone.text.trim(); @@ -223,17 +312,33 @@ class _AlbumFormPageState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - // DefaultTabController 仅为 TabBar 提供 controller(内容区非 - // TabBarView,而是按 _tabIndex 手动切换,避免两 tab 表单失焦重建)。 - return DefaultTabController( - length: 2, + final mainTabs = _mainTabs; + return PopScope( + // 有未保存变更时拦截系统返回/aybePop;保存成功后的程序化 pop 不受 + // 影响(Navigator.pop 绕过 canPop)。 + canPop: !_isDirty, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + _confirmDiscard(); + }, child: Scaffold( appBar: AppBar( - title: Text(_isCreate ? l10n.albumCreate : l10n.albumEdit), + title: Text(_isCreate ? l10n.createAlbumTitle : l10n.editAlbumTitle), + // 编辑模式:Details / Access 页签(共享同一草稿)。 + bottom: mainTabs == null + ? null + : TabBar( + controller: mainTabs, + onTap: (index) => setState(() => _mainIndex = index), + tabs: [ + Tab(text: l10n.formDetailsTab), + Tab(text: l10n.formAccessTab), + ], + ), ), // 保存按钮固定底部:键盘打开时常驻可达(§4.5)。 bottomNavigationBar: SafeArea( - minimum: const EdgeInsets.fromLTRB(16, 8, 16, 12), + minimum: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace16, kSpace12), child: FilledButton( onPressed: _canSubmit ? _submit : null, child: _submitting @@ -242,124 +347,146 @@ class _AlbumFormPageState extends ConsumerState { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : Text(_isCreate ? l10n.create : l10n.save), + : Text(_isCreate ? l10n.createAlbumSubmit : l10n.saveChanges), ), ), - body: Form( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // ── 分组 1:活动信息 ──────────────────────────────── - SectionHeader(title: l10n.albumSectionEventInfo), - const SizedBox(height: kSpace12), - _dateTimeRow(isStart: true), - _dateTimeRow(isStart: false), - const SizedBox(height: 8), - TextFormField( - controller: _timezone, - onChanged: (_) => setState(() {}), - decoration: InputDecoration(labelText: l10n.albumTimezone), - ), - const SizedBox(height: kSpace24), + body: mainTabs == null + ? _detailsForm(l10n) + : _mainIndex == 0 + ? _detailsForm(l10n) + : _accessPanel(l10n), + ), + ); + } - // ── 分组 2:多语言内容(tab 切换,另一语言按需填写)── - SectionHeader(title: l10n.albumSectionContent), - const SizedBox(height: kSpace8), - TabBar( - onTap: (index) => setState(() => _tabIndex = index), - tabs: [ - Tab(text: l10n.localeChinese), - Tab(text: l10n.localeEnglish), - ], - ), - const SizedBox(height: kSpace12), - if (_tabIndex == 0) - _translationFields( - title: _zhTitle, - description: _zhDescription, - venue: _zhVenue, - address: _zhAddress, - isDefault: _defaultLocale == _zh, - ) - else - _translationFields( - title: _enTitle, - description: _enDescription, - venue: _enVenue, - address: _enAddress, - isDefault: _defaultLocale == _en, - ), - const SizedBox(height: kSpace24), + /// Details(= 创建表单):分组字段 + 校验错误汇总。 + Widget _detailsForm(AppLocalizations l10n) { + return Form( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(kSpace16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── 分组 1:Event details ────────────────────────── + SectionHeader(title: l10n.formEventDetails), + const SizedBox(height: kSpace12), + _dateTimeRow(isStart: true), + _dateTimeRow(isStart: false), + const SizedBox(height: kSpace8), + TextFormField( + controller: _timezone, + onChanged: (_) => setState(() {}), + decoration: InputDecoration(labelText: l10n.albumTimezone), + ), + const SizedBox(height: kSpace24), - // ── 分组 3:访问设置 ──────────────────────────────── - SectionHeader(title: l10n.albumSectionAccess), - const SizedBox(height: kSpace12), - Text(l10n.albumDefaultLocale), - const SizedBox(height: kSpace8), - SegmentedButton( - segments: [ - ButtonSegment(value: _zh, label: Text(l10n.localeChinese)), - ButtonSegment(value: _en, label: Text(l10n.localeEnglish)), - ], - selected: {_defaultLocale}, - onSelectionChanged: (selection) => - setState(() => _defaultLocale = selection.first), - ), - const SizedBox(height: kSpace16), - if (!_isCreate) ...[ - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.switchUpload), - value: _uploadEnabled, - onChanged: (v) => setState(() => _uploadEnabled = v), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.switchPublic), - value: _publicEnabled, - onChanged: (v) => setState(() => _publicEnabled = v), - ), - ], - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.switchDownload), - value: _downloadEnabled, - onChanged: (v) => setState(() => _downloadEnabled = v), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.switchOriginalDownload), - value: _originalDownloadEnabled, - onChanged: (v) => - setState(() => _originalDownloadEnabled = v), - ), - if (_errors.isNotEmpty) ...[ - const SizedBox(height: kSpace8), - ..._errors.map( - (e) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - _errorText(l10n, e), - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ), - ], - const SizedBox(height: kSpace24), + // ── 分组 2:Description & languages ─────────────── + SectionHeader(title: l10n.formDescriptionLanguages), + const SizedBox(height: kSpace12), + Text('${l10n.formDefaultLanguage} *'), + const SizedBox(height: kSpace8), + SegmentedButton( + segments: [ + ButtonSegment(value: _zh, label: Text(l10n.localeChinese)), + ButtonSegment(value: _en, label: Text(l10n.localeEnglish)), + ], + selected: {_defaultLocale}, + onSelectionChanged: (selection) => + setState(() => _defaultLocale = selection.first), + ), + const SizedBox(height: kSpace12), + TabBar( + controller: _langTabs, + onTap: (index) => setState(() => _langIndex = index), + tabs: [ + Tab(text: l10n.localeChinese), + Tab(text: l10n.localeEnglish), ], ), - ), + const SizedBox(height: kSpace12), + if (_langIndex == 0) + _translationFields( + title: _zhTitle, + description: _zhDescription, + venue: _zhVenue, + address: _zhAddress, + isDefault: _defaultLocale == _zh, + ) + else + _translationFields( + title: _enTitle, + description: _enDescription, + venue: _enVenue, + address: _enAddress, + isDefault: _defaultLocale == _en, + ), + if (_errors.isNotEmpty) ...[ + const SizedBox(height: kSpace8), + ..._errors.map( + (e) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + _errorText(l10n, e), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ), + ], + const SizedBox(height: kSpace24), + ], ), ), ), ); } + /// Access 页签(仅编辑):四开关各配说明;原图下载受总下载约束。 + Widget _accessPanel(AppLocalizations l10n) { + final theme = Theme.of(context); + final descStyle = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + return ListView( + padding: const EdgeInsets.all(kSpace16), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.formAllowUploads), + subtitle: Text(l10n.formAllowUploadsDesc, style: descStyle), + value: _uploadEnabled, + onChanged: (v) => setState(() => _uploadEnabled = v), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.formPublicAccess), + subtitle: Text(l10n.formPublicAccessDesc, style: descStyle), + value: _publicEnabled, + onChanged: (v) => setState(() => _publicEnabled = v), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.formAllowDownloads), + subtitle: Text(l10n.formAllowDownloadsDesc, style: descStyle), + value: _downloadEnabled, + onChanged: (v) => setState(() => _downloadEnabled = v), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.formOriginalDownloads), + subtitle: Text(l10n.formOriginalDownloadsDesc, style: descStyle), + // 总下载关闭 → 原图下载禁用(生效值恒 false,重开下载恢复原值)。 + value: _downloadEnabled && _originalDownloadEnabled, + onChanged: !_downloadEnabled + ? null + : (v) => setState(() => _originalDownloadEnabled = v), + ), + ], + ); + } + Widget _translationFields({ required TextEditingController title, required TextEditingController description, diff --git a/apps/mobile/lib/pages/album_members_page.dart b/apps/mobile/lib/pages/album_members_page.dart index 05d10da..be9f084 100644 --- a/apps/mobile/lib/pages/album_members_page.dart +++ b/apps/mobile/lib/pages/album_members_page.dart @@ -1,6 +1,9 @@ -/// 成员管理页(P3 计划 Task 4):成员列表(角色标签、owner 徽标、 -/// 本人/创建者不可移除)+ 账号前缀搜索邀请(300ms 防抖、≥2 字符、 -/// lookupUsers 远程搜索)+ 移除确认。MEMBER_CONFLICT 本地化覆盖。 +/// 成员管理页(UI-03 §4 页面 11 重设计):Owner 单独分组(无移除)+ +/// Collaborators 列表(首字母头像、显示名、账号·角色、行尾「⋯」移除); +/// 底部全宽「Invite colleague」打开账号前缀搜索 BottomSheet(300ms 防抖、 +/// ≥2 字符、迟到响应守卫、已成员「Already added」态、MEMBER_CONFLICT +/// 本地化)。移除经确认对话框(含成员名)。本人/创建者不可移除;成员 +/// 分页保留。 library; import 'dart:async'; @@ -14,6 +17,8 @@ import '../core/error_codes.dart'; import '../core/error_l10n.dart'; import '../features/albums/models.dart'; import '../l10n/app_localizations.dart'; +import '../ui/components/section_header.dart'; +import '../ui/tokens.dart'; /// 成员管理(从详情页携带完整 [album] 进入)。 class AlbumMembersPage extends ConsumerStatefulWidget { @@ -38,11 +43,6 @@ class _AlbumMembersPageState extends ConsumerState { /// 当前登录账号(本人移除 UI 拦截用);读取失败按未知处理。 String? _selfAccount; - final TextEditingController _searchController = TextEditingController(); - Timer? _debounce; - bool _searching = false; - List? _results; - @override void initState() { super.initState(); @@ -50,13 +50,6 @@ class _AlbumMembersPageState extends ConsumerState { _reload(); } - @override - void dispose() { - _debounce?.cancel(); - _searchController.dispose(); - super.dispose(); - } - Future _loadSelfAccount() async { try { final session = await ref.read(sessionStoreProvider).read(); @@ -138,7 +131,281 @@ class _AlbumMembersPageState extends ConsumerState { } } - // ── 邀请搜索(防抖 300ms、≥2 字符) ── + // ── 邀请(BottomSheet 搜索选择器) ───────────────────────────── + + Future _openInviteSheet() async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => AlbumInviteSheet( + albumId: widget.album.id, + // 打开时的既有成员(已添加态判定;面板内邀请成功后本地累加)。 + memberUserIds: { + for (final m in _items) m.userId, + }, + memberAccounts: { + for (final m in _items) m.account, + }, + onInvited: _reload, + ), + ); + } + + // ── 移除(行尾「⋯」→ 确认对话框) ───────────────────────────── + + Future _remove(Member member) async { + final l10n = AppLocalizations.of(context); + final name = member.displayName.trim().isNotEmpty + ? member.displayName.trim() + : member.account; + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(l10n.removeMember), + content: Text(l10n.removeMemberConfirm(name)), + 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) + .removeMember(widget.album.id, member.userId); + await _reload(); + } on AppException catch (e) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.messageForError(e))), + ); + } on Object { + messenger.showSnackBar( + SnackBar(content: Text(l10n.errorGeneric)), + ); + } + } + + bool _canRemove(Member member) => + member.role != MemberRole.owner && member.account != _selfAccount; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final owners = _items.where((m) => m.role == MemberRole.owner).toList(); + final collaborators = + _items.where((m) => m.role != MemberRole.owner).toList(); + return Scaffold( + appBar: AppBar( + title: Text( + _loading ? l10n.membersTitle : l10n.membersTitleCount(_items.length), + ), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _errorCode != null + ? _ErrorRetryView( + message: l10n.messageForErrorCode(_errorCode!), + onRetry: _reload, + ) + : _memberGroups(l10n, owners, collaborators), + // 全宽「Invite colleague」:避让安全区,键盘/列表常驻可达。 + bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace16, kSpace12), + child: FilledButton.icon( + onPressed: _loading ? null : _openInviteSheet, + icon: const Icon(Icons.person_add_outlined), + label: Text(l10n.inviteColleague), + ), + ), + ); + } + + /// Owner 单独组 + Collaborators 组;滚动加载更多挂在列表底部。 + Widget _memberGroups( + AppLocalizations l10n, + List owners, + List collaborators, + ) { + final theme = Theme.of(context); + return ListView( + padding: const EdgeInsets.fromLTRB(kSpace16, kSpace16, kSpace16, kSpace24), + children: [ + SectionHeader(title: l10n.ownerGroup), + const SizedBox(height: kSpace8), + Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (final (i, member) in owners.indexed) ...[ + if (i > 0) const Divider(height: 1, indent: kSpace16, endIndent: kSpace16), + _MemberRow( + member: member, + isSelf: member.account == _selfAccount, + onRemove: null, // 创建者不可移除(无「⋯」) + ), + ], + ], + ), + ), + const SizedBox(height: kSpace24), + SectionHeader(title: l10n.collaboratorsGroup), + const SizedBox(height: kSpace8), + if (collaborators.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: kSpace12), + child: Text( + l10n.noCollaborators, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else + Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (final (i, member) in collaborators.indexed) ...[ + if (i > 0) const Divider(height: 1, indent: kSpace16, endIndent: kSpace16), + _MemberRow( + member: member, + isSelf: member.account == _selfAccount, + onRemove: _canRemove(member) ? () => _remove(member) : null, + ), + ], + ], + ), + ), + if (_hasMore || _loadingMore) + Padding( + padding: const EdgeInsets.symmetric(vertical: kSpace16), + child: Center( + child: _loadingMore + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : // 滚到底部自动取下一页(沿用旧列表的触发时机)。 + _LoadMoreTrigger(onTriggered: _loadMore), + ), + ), + ], + ); + } +} + +/// 成员行:首字母头像 + 显示名(本人标记)+ 账号·角色 + 行尾「⋯」(仅可 +/// 移除成员显示;点击直接进入移除确认)。 +class _MemberRow extends StatelessWidget { + const _MemberRow({ + required this.member, + required this.isSelf, + this.onRemove, + }); + + final Member member; + final bool isSelf; + + /// 移除回调;null = 不显示「⋯」(创建者/本人)。 + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final name = member.displayName.trim().isNotEmpty + ? member.displayName.trim() + : member.account; + final role = member.role == MemberRole.owner + ? l10n.roleOwner + : l10n.roleCollaborator; + return ListTile( + leading: CircleAvatar(child: Text(name.substring(0, 1).toUpperCase())), + title: Text(isSelf ? '$name${l10n.selfMarker}' : name), + subtitle: Text('${member.account} · $role'), + trailing: onRemove == null + ? null + : IconButton( + tooltip: l10n.removeMember, + icon: const Icon(Icons.more_horiz), + onPressed: onRemove, + ), + ); + } +} + +/// 「加载更多」触发器:布局进列表尾部即触发一次下一页请求。 +class _LoadMoreTrigger extends StatelessWidget { + const _LoadMoreTrigger({required this.onTriggered}); + + final VoidCallback onTriggered; + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) => onTriggered()); + return const SizedBox(height: 24, width: 24); + } +} + +/// 邀请同事 BottomSheet:账号前缀搜索(防抖 300ms、≥2 字符)→ 结果行 +/// (头像 + 显示名 + 账号 + Add);已成员显示「Already added」不可再加。 +/// 邀请成功提示 + 回调上级刷新列表;本地累加已添加集合保持面板状态。 +class AlbumInviteSheet extends ConsumerStatefulWidget { + const AlbumInviteSheet({ + required this.albumId, + required this.memberUserIds, + required this.memberAccounts, + required this.onInvited, + super.key, + }); + + final String albumId; + + /// 打开面板时的成员用户 ID 集合。 + final Set memberUserIds; + + /// 打开面板时的成员账号集合(按账号兜底判重)。 + final Set memberAccounts; + + /// 邀请成功回调(上级刷新成员列表)。 + final VoidCallback onInvited; + + @override + ConsumerState createState() => _AlbumInviteSheetState(); +} + +class _AlbumInviteSheetState extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + Timer? _debounce; + bool _searching = false; + List? _results; + + /// 面板生命周期内新增的成员(已添加态即时生效)。 + final Set _addedIds = {}; + + @override + void dispose() { + _debounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + bool _isMember(AdminUserLookupItem user) => + widget.memberUserIds.contains(user.id) || + _addedIds.contains(user.id) || + widget.memberAccounts.contains(user.account); void _onSearchChanged(String text) { _debounce?.cancel(); @@ -188,17 +455,14 @@ class _AlbumMembersPageState extends ConsumerState { final l10n = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); try { - await ref.read(apiClientProvider).addMember( - widget.album.id, - user.id, - ); + await ref.read(apiClientProvider).addMember(widget.albumId, user.id); if (!mounted) return; + _addedIds.add(user.id); + setState(() {}); messenger.showSnackBar( SnackBar(content: Text(l10n.memberInvited)), ); - _searchController.clear(); - setState(() => _results = null); - await _reload(); + widget.onInvited(); } on AppException catch (e) { messenger.showSnackBar( SnackBar( @@ -216,98 +480,62 @@ class _AlbumMembersPageState extends ConsumerState { } } - Future _remove(Member member) async { - final l10n = AppLocalizations.of(context); - final confirmed = await showDialog( - context: context, - builder: (_) => AlertDialog( - title: Text(l10n.removeMember), - content: Text(l10n.removeMemberConfirm), - 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) - .removeMember(widget.album.id, member.userId); - await _reload(); - } on AppException catch (e) { - messenger.showSnackBar( - SnackBar(content: Text(l10n.messageForError(e))), - ); - } on Object { - messenger.showSnackBar( - SnackBar(content: Text(l10n.errorGeneric)), - ); - } - } - - bool _canRemove(Member member) => - member.role != MemberRole.owner && member.account != _selfAccount; - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - return Scaffold( - appBar: AppBar(title: Text(l10n.albumMembers)), - body: Column( + return Padding( + // 键盘避让:输入框跟随视口内边距上移。 + padding: EdgeInsets.fromLTRB( + kSpace16, + 0, + kSpace16, + MediaQuery.of(context).viewInsets.bottom + kSpace16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: TextField( - controller: _searchController, - onChanged: _onSearchChanged, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.person_search_outlined), - hintText: l10n.inviteSearchHint, - border: const OutlineInputBorder(), - suffixIcon: _searching - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : (_searchController.text.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - _onSearchChanged(''); - }, - )), - ), + TextField( + controller: _searchController, + autofocus: true, + onChanged: _onSearchChanged, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.person_search_outlined), + hintText: l10n.inviteSearchHint, + border: const OutlineInputBorder(), + suffixIcon: _searching + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : (_searchController.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + )), ), ), - if (_results != null) _searchResults(l10n), - const Divider(height: 1, indent: 16, endIndent: 16), - Expanded(child: _memberList(l10n)), + const SizedBox(height: kSpace8), + if (_results != null) Flexible(child: _resultsList(l10n)), ], ), ); } - /// 搜索结果面板(有限高度内滚动;无结果显示占位文案)。 - Widget _searchResults(AppLocalizations l10n) { + /// 结果列表(面板高度内滚动;无结果显示占位文案)。 + Widget _resultsList(AppLocalizations l10n) { final results = _results!; if (results.isEmpty) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: kSpace12), child: Text( l10n.inviteNoResults, textAlign: TextAlign.center, @@ -315,136 +543,60 @@ class _AlbumMembersPageState extends ConsumerState { ), ); } - return ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 220), - child: ListView( - shrinkWrap: true, - children: [ - for (final user in results) - ListTile( - dense: true, - leading: CircleAvatar(child: Text(user.account.substring(0, 1).toUpperCase())), - title: Text(user.account), - subtitle: Text(user.displayName), - trailing: FilledButton.tonal( - onPressed: () => _invite(user), - child: Text(l10n.invite), + return ListView( + shrinkWrap: true, + children: [ + for (final user in results) + ListTile( + dense: true, + leading: CircleAvatar( + child: Text( + user.displayName.trim().isNotEmpty + ? user.displayName.trim().substring(0, 1).toUpperCase() + : user.account.substring(0, 1).toUpperCase(), ), ), - ], - ), - ); - } - - Widget _memberList(AppLocalizations l10n) { - if (_loading) { - return const Center(child: CircularProgressIndicator()); - } - if (_items.isEmpty) { - if (_errorCode != null) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.cloud_off, size: 48), - const SizedBox(height: 12), - Text(l10n.messageForErrorCode(_errorCode!)), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: _reload, - icon: const Icon(Icons.refresh), - label: Text(l10n.retry), - ), - ], + title: Text(user.displayName), + subtitle: Text(user.account), + trailing: _isMember(user) + ? Text( + l10n.alreadyAdded, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ) + : FilledButton.tonal( + onPressed: () => _invite(user), + child: Text(l10n.add), + ), ), - ); - } - return Center(child: Text(l10n.albumMembersEmpty)); - } - return ListView.builder( - itemCount: _items.length + (_hasMore || _loadingMore ? 1 : 0), - itemBuilder: (context, index) { - if (index >= _items.length) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ); - } - if (index >= _items.length - 3 && _hasMore && !_loadingMore) { - WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore()); - } - final member = _items[index]; - final isSelf = member.account == _selfAccount; - return ListTile( - leading: CircleAvatar( - child: Text( - member.account.substring(0, 1).toUpperCase(), - ), - ), - title: Text( - isSelf ? '${member.account}${l10n.selfMarker}' : member.account, - ), - subtitle: Text(member.displayName), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _RoleChip(role: member.role), - if (_canRemove(member)) - IconButton( - tooltip: l10n.removeMember, - icon: const Icon(Icons.person_remove_outlined), - onPressed: () => _remove(member), - ), - ], - ), - ); - }, + ], ); } } -/// 角色标签:owner 附星标(创建者徽标)。 -class _RoleChip extends StatelessWidget { - const _RoleChip({required this.role}); +/// 首屏错误 + 重试。 +class _ErrorRetryView extends StatelessWidget { + const _ErrorRetryView({required this.message, required this.onRetry}); - final MemberRole role; + final String message; + final Future Function() onRetry; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final theme = Theme.of(context); - final owner = role == MemberRole.owner; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: owner - ? theme.colorScheme.tertiaryContainer - : theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - ), - child: Row( + return Center( + child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (owner) - Icon( - Icons.star, - size: 12, - color: theme.colorScheme.onTertiaryContainer, - ), - if (owner) const SizedBox(width: 4), - Text( - owner ? l10n.roleOwner : l10n.roleCollaborator, - style: theme.textTheme.labelSmall?.copyWith( - color: owner - ? theme.colorScheme.onTertiaryContainer - : theme.colorScheme.onSurfaceVariant, - ), + 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), ), ], ), diff --git a/apps/mobile/test/pages/album_categories_page_test.dart b/apps/mobile/test/pages/album_categories_page_test.dart new file mode 100644 index 0000000..cae380f --- /dev/null +++ b/apps/mobile/test/pages/album_categories_page_test.dart @@ -0,0 +1,328 @@ +/// 相册分类独立管理页 widget 测试(UI-03 §4 页面 12,自详情页提取)。 +/// +/// 覆盖:列表(默认语言名 + 排序序号 + 行尾「⋯」)与管理者新增入口; +/// BottomSheet 编辑器(语言页签 + 名称 + 排序序号)新增载荷/空名校验/ +/// 编辑 PATCH 全量替换;删除确认提及照片影响;空态;协作者仅浏览。 +library; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/features/albums/models.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/album_categories_page.dart'; + +import '../helpers/fakes.dart'; + +void main() { + late FakeSessionStore store; + late List> categories; + + setUp(() { + store = FakeSessionStore(); + categories = >[ + categoryJson(id: 'cat-1', sortOrder: 0), + categoryJson( + id: 'cat-2', + sortOrder: 1, + translations: >[ + {'locale': 'zh-CN', 'name': '展厅'}, + {'locale': 'en', 'name': 'Exhibition Hall'}, + ], + ), + ]; + }); + + /// 路由:GET 详情(反映可变分类表)/ 分类 CRUD;其余路径直接抛错。 + FakeAdapter categoriesAdapter({ + (int, Object?)? Function(String method, String path)? onMutation, + }) => + FakeAdapter((o) { + if (o.path == '/v1/albums/alb-1' && o.method == 'GET') { + return (200, albumJson(categories: categories)); + } + if (o.path == '/v1/albums/alb-1/categories' && o.method == 'POST') { + return onMutation?.call('POST', o.path) ?? + (201, categoryJson(id: 'cat-new')); + } + if (o.path.startsWith('/v1/categories/') && o.method == 'PATCH') { + return onMutation?.call('PATCH', o.path) ?? + (200, categoryJson(id: 'cat-1')); + } + if (o.path.startsWith('/v1/categories/') && o.method == 'DELETE') { + return onMutation?.call('DELETE', o.path) ?? (204, null); + } + throw StateError('unexpected ${o.method} ${o.path}'); + }); + + ApiClient clientOf(FakeAdapter adapter) => ApiClient( + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + Future pumpCategories( + WidgetTester tester, + ApiClient client, { + bool isManager = true, + List>? initial, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + apiClientProvider.overrideWith((ref) => client), + sessionStoreProvider.overrideWith((ref) => store), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AlbumCategoriesPage( + album: Album.fromJson(albumJson(categories: initial ?? categories)), + isManager: isManager, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('列表:默认语言名 + 排序序号 + 行尾「⋯」+ 管理者新增入口', + (tester) async { + await pumpCategories(tester, clientOf(categoriesAdapter())); + + expect(find.text('Categories'), findsOneWidget); + // 默认语言(相册 defaultLocale=zh-CN)名称。 + expect(find.text('仪式'), findsOneWidget); + expect(find.text('展厅'), findsOneWidget); + expect(find.text('Ceremony'), findsNothing, reason: '非默认语言不占主行'); + // 排序序号 + 每行「⋯」。 + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.byIcon(Icons.more_horiz), findsNWidgets(2)); + expect(find.widgetWithText(FilledButton, 'New category'), findsOneWidget); + }); + + testWidgets('新增:语言页签 + 名称 + 排序 → POST 载荷 → 列表刷新', + (tester) async { + var posted = false; + final adapter = categoriesAdapter( + onMutation: (method, path) { + if (method == 'POST') { + posted = true; + categories = >[ + ...categories, + categoryJson( + id: 'cat-new', + sortOrder: 5, + translations: >[ + {'locale': 'zh-CN', 'name': '签到'}, + {'locale': 'en', 'name': 'Check-in'}, + ], + ), + ]; + return (201, categoryJson(id: 'cat-new', sortOrder: 5)); + } + return null; + }, + ); + await pumpCategories(tester, clientOf(adapter)); + + await tester.tap(find.text('New category')); + await tester.pumpAndSettle(); + + // 编辑器:语言页签(默认 zh,与相册 defaultLocale 一致)。 + expect(find.text('New category'), findsNWidgets(2), reason: '按钮与面板标题'); + final sheet = find.byType(CategoryEditSheet); + expect(sheet, findsOneWidget); + expect( + find.descendant(of: sheet, matching: find.byType(TabBar)), + findsOneWidget, + ); + + // zh 页签名称 + 排序;切 en 页签填英文名。 + await tester.enterText( + find.widgetWithText(TextFormField, 'Chinese name'), + '签到', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Sort order'), + '5', + ); + await tester.tap( + find.descendant(of: sheet, matching: find.text('English')), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextFormField, 'English name'), + 'Check-in', + ); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(posted, isTrue); + final request = adapter.requests.singleWhere( + (r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/categories', + ); + expect(request.body, { + 'sortOrder': 5, + 'translations': >[ + {'locale': 'zh-CN', 'name': '签到'}, + {'locale': 'en', 'name': 'Check-in'}, + ], + }); + // 成功后重载详情(GET /albums/alb-1),列表含新分类。 + expect(find.text('签到'), findsOneWidget); + }); + + testWidgets('新增:名称全空 → 客户端拦截,不发请求', (tester) async { + final adapter = categoriesAdapter(); + await pumpCategories(tester, clientOf(adapter)); + + final requestsBefore = adapter.requests.length; + await tester.tap(find.text('New category')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pump(); + + expect( + find.text('At least one language name is required'), + findsOneWidget, + ); + expect(adapter.requests, hasLength(requestsBefore)); + }); + + testWidgets('编辑:「⋯」→ Edit category → 预填改名 → PATCH 全量 translations', + (tester) async { + var patched = false; + final adapter = categoriesAdapter( + onMutation: (method, path) { + if (method == 'PATCH') { + patched = true; + categories = >[ + categoryJson( + id: 'cat-1', + sortOrder: 2, + translations: >[ + {'locale': 'zh-CN', 'name': '签到墙'}, + {'locale': 'en', 'name': 'Ceremony'}, + ], + ), + categories[1], + ]; + return (200, categoryJson(id: 'cat-1')); + } + return null; + }, + ); + await pumpCategories(tester, clientOf(adapter)); + + // 第一行「⋯」→ Edit category。 + await tester.tap(find.byIcon(Icons.more_horiz).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit category')); + await tester.pumpAndSettle(); + + // 预填检查(zh 名称)。 + final zhField = tester.widget( + find.widgetWithText(TextFormField, 'Chinese name'), + ); + expect(zhField.controller!.text, '仪式'); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Chinese name'), + '签到墙', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Sort order'), + '2', + ); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(patched, isTrue); + final request = adapter.requests.singleWhere( + (r) => r.method == 'PATCH' && r.path == '/v1/categories/cat-1', + ); + expect(request.body, { + 'sortOrder': 2, + 'translations': >[ + {'locale': 'zh-CN', 'name': '签到墙'}, + {'locale': 'en', 'name': 'Ceremony'}, + ], + }); + expect(find.text('签到墙'), findsOneWidget); + }); + + testWidgets('删除:确认提及名称与照片影响 → DELETE → 列表刷新 + 空态', + (tester) async { + final adapter = categoriesAdapter( + onMutation: (method, path) { + if (method == 'DELETE') { + categories = categories.where((c) => c['id'] != 'cat-1').toList(); + return (204, null); + } + return null; + }, + ); + await pumpCategories(tester, clientOf(adapter)); + + await tester.tap(find.byIcon(Icons.more_horiz).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + // 确认文案:默认语言名 + 照片影响说明。 + expect( + find.text('Delete "仪式"? Its photos will become uncategorized.'), + findsOneWidget, + ); + // 取消可用:先取消再重删,验证确认门。 + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('仪式'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.more_horiz).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect( + adapter.requests.any( + (r) => r.method == 'DELETE' && r.path == '/v1/categories/cat-1', + ), + isTrue, + ); + expect(find.text('仪式'), findsNothing); + expect(find.text('展厅'), findsOneWidget); + }); + + testWidgets('空分类:安静空态 + 新增入口', (tester) async { + await pumpCategories( + tester, + clientOf(categoriesAdapter()), + initial: >[], + ); + + expect(find.text('No categories yet'), findsOneWidget); + expect(find.byIcon(Icons.category_outlined), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'New category'), findsOneWidget); + }); + + testWidgets('协作者(isManager=false):仅浏览,无管理入口', (tester) async { + await pumpCategories( + tester, + clientOf(categoriesAdapter()), + isManager: false, + ); + + expect(find.text('仪式'), findsOneWidget); + expect(find.text('展厅'), findsOneWidget); + expect(find.byIcon(Icons.more_horiz), findsNothing); + expect(find.text('New category'), findsNothing); + }); +} diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart index 732579f..90f2a0d 100644 --- a/apps/mobile/test/pages/album_detail_page_test.dart +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -3,9 +3,8 @@ /// 覆盖:封面 → 标题/时区时间场地 → 状态徽章 → 次级四入口(Photos/ /// Members/Categories/Share)→ Recent photos 三列网格 + See all → 底部 /// 固定上传栏(拍摄上传主 / 从相册选择次,上传关闭时禁用并说明);空相册 -/// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类管理三流 -/// (新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍页与多选 -/// 入队。 +/// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类入口跳转 +/// 独立分类页(页面 12,旧内嵌区块已移除);底部栏直达连拍页与多选入队。 library; import 'dart:io'; @@ -24,6 +23,7 @@ import 'package:photo_live/features/uploads/preparer.dart'; import 'package:photo_live/features/uploads/providers.dart'; import 'package:photo_live/features/uploads/task_store.dart'; import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/album_categories_page.dart'; import 'package:photo_live/pages/album_detail_page.dart'; import 'package:photo_live/pages/capture_page.dart'; @@ -270,186 +270,38 @@ void main() { expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget); }); - testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async { - var posted = false; - final adapter = detailAdapter( - onCategoryMutation: (method, path) { - if (method == 'POST') { - posted = true; - categories = >[ - ...categories, - categoryJson( - id: 'cat-new', - sortOrder: 5, - translations: >[ - {'locale': 'zh-CN', 'name': '签到'}, - {'locale': 'en', 'name': 'Check-in'}, - ], - ), - ]; - return (201, categoryJson(id: 'cat-new', sortOrder: 5)); - } - return null; - }, - ); - await pumpDetail(tester, clientOf(adapter)); - - await reveal(tester, find.byIcon(Icons.add_circle_outline)); - await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable()); - await tester.pumpAndSettle(); - - await tester.enterText( - find.widgetWithText(TextFormField, 'Chinese name'), - '签到', - ); - await tester.enterText( - find.widgetWithText(TextFormField, 'English name'), - 'Check-in', - ); - await tester.enterText( - find.widgetWithText(TextFormField, 'Sort order'), - '5', - ); - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(posted, isTrue); - // 成功后详情重载会追加 GET,故按方法+路径取 POST。 - final request = adapter.requests.singleWhere( - (r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/categories', - ); - expect(request.path, '/v1/albums/alb-1/categories'); - expect(request.body, { - 'sortOrder': 5, - 'translations': >[ - {'locale': 'zh-CN', 'name': '签到'}, - {'locale': 'en', 'name': 'Check-in'}, - ], - }); - // 详情重载后列表含新分类。 - expect(find.text('签到 · Check-in'), findsOneWidget); - }); - - testWidgets('新增分类:名称全空 → 客户端拦截,不发请求', (tester) async { + testWidgets('分类入口:跳转独立分类页(旧内嵌区块已移除)', (tester) async { final adapter = detailAdapter(); await pumpDetail(tester, clientOf(adapter)); - final requestsBefore = adapter.requests.length; - await reveal(tester, find.byIcon(Icons.add_circle_outline)); - await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save')); - await tester.pump(); - - expect( - find.text('At least one language name is required'), - findsOneWidget, - ); - expect(adapter.requests, hasLength(requestsBefore)); - }); - - testWidgets('编辑分类:预填并改名 → PATCH 全量 translations + sortOrder', - (tester) async { - var patched = false; - final adapter = detailAdapter( - onCategoryMutation: (method, path) { - if (method == 'PATCH') { - patched = true; - categories = >[ - categoryJson( - id: 'cat-1', - sortOrder: 2, - translations: >[ - {'locale': 'zh-CN', 'name': '签到墙'}, - {'locale': 'en', 'name': 'Ceremony'}, - ], - ), - ]; - return (200, categoryJson(id: 'cat-1')); - } - return null; - }, - ); - await pumpDetail(tester, clientOf(adapter)); - - await reveal(tester, find.text('仪式 · Ceremony')); - await tester.tap(find.text('仪式 · Ceremony').hitTestable()); - await tester.pumpAndSettle(); - - // 预填检查。 - final zhField = tester.widget( - find.widgetWithText(TextFormField, 'Chinese name'), - ); - expect(zhField.controller!.text, '仪式'); - - await tester.enterText( - find.widgetWithText(TextFormField, 'Chinese name'), - '签到墙', - ); - await tester.enterText( - find.widgetWithText(TextFormField, 'Sort order'), - '2', - ); - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(patched, isTrue); - // 成功后详情重载会追加 GET,故按方法+路径取 PATCH。 - final request = adapter.requests.singleWhere( - (r) => r.method == 'PATCH' && r.path == '/v1/categories/cat-1', - ); - expect(request.body, { - 'sortOrder': 2, - 'translations': >[ - {'locale': 'zh-CN', 'name': '签到墙'}, - {'locale': 'en', 'name': 'Ceremony'}, - ], - }); - expect(find.text('签到墙 · Ceremony'), findsOneWidget); - }); - - testWidgets('删除分类:确认对话框 → DELETE → 列表移除', (tester) async { - final adapter = detailAdapter( - onCategoryMutation: (method, path) { - if (method == 'DELETE') { - categories = >[]; - return (204, null); - } - return null; - }, - ); - await pumpDetail(tester, clientOf(adapter)); - - await reveal(tester, find.byIcon(Icons.delete_outline)); - await tester.tap(find.byIcon(Icons.delete_outline).hitTestable()); - await tester.pumpAndSettle(); - - expect( - find.text('Delete this category? Its photos will become uncategorized.'), - findsOneWidget, - ); - // 取消可用:先取消再重删,验证确认门。 - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - expect(find.text('仪式 · Ceremony'), findsOneWidget); - - await reveal(tester, find.byIcon(Icons.delete_outline)); - await tester.tap(find.byIcon(Icons.delete_outline).hitTestable()); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, 'Delete')); - await tester.pumpAndSettle(); - - expect( - adapter.requests.any( - (r) => r.method == 'DELETE' && r.path == '/v1/categories/cat-1', - ), - isTrue, - ); + // 旧内嵌分类管理区块不再存在于详情页。 + expect(find.byIcon(Icons.add_circle_outline), findsNothing); expect(find.text('仪式 · Ceremony'), findsNothing); - expect(find.text('No categories yet. Tap + to add one'), findsOneWidget); + + // Categories 入口 → 独立分类页(页面 12)。入口在折叠线下,先滚动 + // 可见再点击。 + final tilesCard = find.byType(Card).first; + final categoriesTile = + find.descendant(of: tilesCard, matching: find.text('Categories')); + await reveal(tester, categoriesTile); + await tester.tap(categoriesTile); + await tester.pumpAndSettle(); + expect(find.byType(AlbumCategoriesPage), findsOneWidget); + expect(find.text('仪式'), findsOneWidget, reason: '分类列表(默认语言名)'); + + // 返回后详情重载(分类随详情返回)。 + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.byType(AlbumCategoriesPage), findsNothing); + expect( + adapter.requests + .where((r) => r.method == 'GET' && r.path == '/v1/albums/alb-1') + .length, + greaterThanOrEqualTo(2), + ); }); + group('底部固定上传栏(原 Task 9 FAB 流迁移)', () { late Directory tmp; late List galleryPaths; diff --git a/apps/mobile/test/pages/album_form_page_test.dart b/apps/mobile/test/pages/album_form_page_test.dart index 2e9a1be..6d92393 100644 --- a/apps/mobile/test/pages/album_form_page_test.dart +++ b/apps/mobile/test/pages/album_form_page_test.dart @@ -1,9 +1,11 @@ -/// 相册创建/编辑表单 widget 测试(P3 计划 Task 4)。 +/// 相册创建/编辑表单 widget 测试(UI-03 §4 页面 09-10 重设计后)。 /// -/// 覆盖:校验矩阵(默认语言标题缺失/未选开始时间/endAt 早于 startAt)、 -/// 创建成功 POST 载荷形状、编辑预加载(字段与四开关初值、创建仅两开关)、 -/// PATCH 最小化(仅拨动的开关出现在请求体)、endAt 显式清除、 -/// 标题修改仅发全量 translations。 +/// 覆盖:创建两分组结构(Event details / Description & languages)与默认 +/// 语言必选、无 Access 页签;校验矩阵(默认语言标题缺失/未选开始时间/ +/// endAt 早于 startAt);创建 POST 载荷形状;编辑 Details/Access 页签共享 +/// 草稿(切页签不丢输入)、四开关与说明、原图下载受总下载约束(禁用 + +/// PATCH 联动关断)、PATCH 最小化、endAt 显式清除、标题修改仅发全量 +/// translations;未保存退出确认;保存失败保留输入。 library; import 'package:dio/dio.dart'; @@ -11,6 +13,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/error_codes.dart'; import 'package:photo_live/features/albums/album_form.dart'; import 'package:photo_live/features/albums/models.dart'; import 'package:photo_live/l10n/app_localizations.dart'; @@ -78,43 +81,43 @@ void main() { await tester.tap(finder); } - testWidgets('三分组结构(UI-01 §4.5):活动信息/多语言内容/访问设置 + 双语 tab', + /// 切到 Access 页签(编辑模式顶栏 TabBar)。 + Future openAccessTab(WidgetTester tester) async { + await tester.tap(find.text('Access')); + await tester.pumpAndSettle(); + } + + testWidgets('页面 09 创建:两分组 + 默认语言必选 + 双语 tab + 无 Access 页签', (tester) async { final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); await pumpForm(tester, clientOf(adapter)); - // 三个分组标题(SectionHeader)。 - expect(find.text('Event info'), findsOneWidget); - expect(find.text('Multilingual content'), findsOneWidget); - expect(find.text('Access settings'), findsOneWidget); - - // 多语言内容:zh-CN/en 双语 tab(当前 zh tab → 标题已填默认语言星标)。 - // 「中文/English」同时出现在内容 tab 与默认语言分段选择中,需锚定 TabBar。 - final tabBar = find.byType(TabBar); - expect(find.descendant(of: tabBar, matching: find.text('中文')), findsOneWidget); + expect(find.text('Create album'), findsNWidgets(2), reason: '标题与底部按钮'); + // 两个分组标题(SectionHeader)。 + expect(find.text('Event details'), findsOneWidget); + expect(find.text('Description & languages'), findsOneWidget); + // 默认语言选择器(必选标记)+ 语言 tab(创建模式仅一个 TabBar)。 + expect(find.text('Default language *'), findsOneWidget); + expect(find.byType(TabBar), findsOneWidget); + expect(find.descendant(of: find.byType(TabBar), matching: find.text('中文')), + findsOneWidget); expect( - find.descendant(of: tabBar, matching: find.text('English')), findsOneWidget); - expect(find.text('Title *'), findsOneWidget, reason: '默认语言标题必填星标'); - - // 切到 en tab → 英文字段(非默认语言,无星标)。 - await tester.tap(find.descendant(of: tabBar, matching: find.text('English'))); - await tester.pumpAndSettle(); - expect(find.text('Title'), findsOneWidget); - expect(find.text('Title *'), findsNothing); - - // 访问设置:默认语言分段选择(两项)。 + find.descendant(of: find.byType(TabBar), matching: find.text('English')), + findsOneWidget); expect(find.byType(SegmentedButton), findsOneWidget); + // 创建模式:无 Access 页签、无开关(契约缺省)。 + expect(find.text('Access'), findsNothing); + expect(find.byType(SwitchListTile), findsNothing); + // 默认时区显式可见(不静默取设备时区)。 + expect(find.text('Asia/Shanghai'), findsOneWidget); }); - testWidgets('创建:空表单提交 → 汇总错误(默认语言标题/开始时间),不发请求', + testWidgets('页面 09 创建:空表单提交 → 汇总错误(默认语言标题/开始时间),不发请求', (tester) async { final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); await pumpForm(tester, clientOf(adapter)); - // 创建模式仅两开关(download/original)。 - expect(find.byType(SwitchListTile), findsNWidgets(2)); - - await tapVisible(tester, find.text('Create')); + await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album')); await tester.pump(); expect(find.text('Default locale must have a title'), findsOneWidget); @@ -123,7 +126,7 @@ void main() { expect(adapter.requests, isEmpty); }); - testWidgets('创建:defaultLocale=en 而 en 标题空 → 默认语言覆盖错误', + testWidgets('页面 09 创建:defaultLocale=en 而 en 标题空 → 默认语言覆盖错误', (tester) async { final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); await pumpForm( @@ -132,14 +135,14 @@ void main() { initialDraft: createDraft(defaultLocale: 'en', enTitle: ''), ); - await tapVisible(tester, find.text('Create')); + await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album')); await tester.pump(); expect(find.text('Default locale must have a title'), findsOneWidget); expect(adapter.requests, isEmpty); }); - testWidgets('创建:endAt 早于 startAt → 时间序错误', (tester) async { + testWidgets('页面 09 创建:endAt 早于 startAt → 时间序错误', (tester) async { final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); await pumpForm( tester, @@ -149,14 +152,14 @@ void main() { ), ); - await tapVisible(tester, find.text('Create')); + await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album')); await tester.pump(); expect(find.text('End time must be after start time'), findsOneWidget); expect(adapter.requests, isEmpty); }); - testWidgets('创建:合法提交 → POST 载荷形状(双语 translations/UTC 时间/开关)', + testWidgets('页面 09 创建:合法提交 → POST 载荷形状(双语 translations/UTC 时间)', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums' @@ -168,12 +171,10 @@ void main() { clientOf(adapter), initialDraft: createDraft( endAt: DateTime.utc(2026, 9, 19, 10, 0), - downloadEnabled: false, - originalDownloadEnabled: true, ), ); - await tapVisible(tester, find.text('Create')); + await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album')); await tester.pumpAndSettle(); final request = adapter.requests.single; @@ -200,12 +201,37 @@ void main() { 'startAt': '2026-09-18T06:30:00.000Z', 'endAt': '2026-09-19T10:00:00.000Z', 'timezone': 'Asia/Shanghai', - 'downloadEnabled': false, - 'originalDownloadEnabled': true, + // 创建无 Access 页签:download/original 走契约缺省。 + 'downloadEnabled': true, + 'originalDownloadEnabled': false, }); }); - testWidgets('编辑:预加载既有值(字段、四开关独立初值、无变更时保存禁用)', + testWidgets('页面 09 创建:保存失败 → 提示并保留输入(不弹回)', (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? (500, errorBody(ErrorCodes.internalError)) + : throw StateError('unexpected ${o.path}'), + ); + await pumpForm(tester, clientOf(adapter), initialDraft: createDraft()); + + await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album')); + await tester.pumpAndSettle(); + + expect(find.byType(AlbumFormPage), findsOneWidget, reason: '失败不弹回'); + expect( + find.text('Service temporarily unavailable. Please retry later'), + findsOneWidget, + ); + // 输入保留。 + expect( + tester.widget(find.byType(TextFormField).at(1)).controller! + .text, + '开幕晚宴', + ); + }); + + testWidgets('页面 10 编辑:Details/Access 页签 + 预填 + 无变更保存禁用', (tester) async { final adapter = FakeAdapter( (o) => throw StateError('编辑预加载不应发起请求:${o.path}'), @@ -219,14 +245,98 @@ void main() { ); await pumpForm(tester, clientOf(adapter), existing: existing); - // 编辑模式四开关。 - expect(find.byType(SwitchListTile), findsNWidgets(4)); - // 字段预填(当前 zh tab;UI-01 分组后 TextFormField 顺序 = 时区 → - // 标题(zh tab),标题取第 2 个)。 + // 顶栏 TabBar(Details/Access)+ 内容区语言 TabBar。 + expect(find.text('Edit album'), findsOneWidget); + expect(find.text('Details'), findsOneWidget); + expect(find.text('Access'), findsOneWidget); + expect(find.byType(TabBar), findsNWidgets(2)); + expect(find.text('Save changes'), findsOneWidget); + // 字段预填(zh tab;TextFormField 顺序 = 时区 → 标题(zh tab))。 final titleField = tester.widget( find.byType(TextFormField).at(1), ); expect(titleField.controller!.text, '开幕晚宴'); + // 时间回显按相册时区。 + expect(find.text('2026/9/18 14:30'), findsOneWidget); + expect(find.text('2026/9/19 18:00'), findsOneWidget); + // 无任何变更 → 保存禁用(PATCH minProperties 1)。 + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + }); + + testWidgets('页面 10 编辑:切页签共享草稿(不丢输入、不丢开关)', (tester) async { + final adapter = FakeAdapter( + (o) => throw StateError('不应发起请求:${o.path}'), + ); + await pumpForm( + tester, + clientOf(adapter), + existing: Album.fromJson(albumJson()), + ); + + // Details 页签输入标题。 + await tester.enterText(find.byType(TextFormField).at(1), '闭幕演出'); + await tester.pump(); + // 切 Access → 切回 Details:输入保留(共享草稿)。 + await openAccessTab(tester); + expect(find.byType(SwitchListTile), findsNWidgets(4)); + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextFormField).at(1)).controller! + .text, + '闭幕演出', + ); + // 未保存 → 不发请求。 + expect(adapter.requests, isEmpty); + }); + + testWidgets('页面 10 编辑:Access 四开关各配说明', (tester) async { + final adapter = FakeAdapter( + (o) => throw StateError('不应发起请求:${o.path}'), + ); + await pumpForm( + tester, + clientOf(adapter), + existing: Album.fromJson(albumJson()), + ); + + await openAccessTab(tester); + + expect( + find.widgetWithText(SwitchListTile, 'Allow uploads'), + findsOneWidget, + ); + expect( + find.text('Allow staff to upload photos to this album'), + findsOneWidget, + ); + expect( + find.widgetWithText(SwitchListTile, 'Public access'), + findsOneWidget, + ); + expect( + find.text('Anyone with the link can view photos'), + findsOneWidget, + ); + expect( + find.widgetWithText(SwitchListTile, 'Allow downloads'), + findsOneWidget, + ); + expect( + find.text('Viewers can download browse-quality photos'), + findsOneWidget, + ); + expect( + find.widgetWithText(SwitchListTile, 'Original downloads'), + findsOneWidget, + ); + expect( + find.text('Viewers can download original photos'), + findsOneWidget, + ); // 开关初值与原相册一致。 expect( tester @@ -239,30 +349,47 @@ void main() { expect( tester .widget( - find.widgetWithText(SwitchListTile, 'Publicly visible'), - ) - .value, - false, - ); - expect( - tester - .widget( - find.widgetWithText(SwitchListTile, 'Allow original downloads'), + find.widgetWithText(SwitchListTile, 'Public access'), ) .value, true, ); - // 时间回显按相册时区。 - expect(find.text('2026/9/18 14:30'), findsOneWidget); - expect(find.text('2026/9/19 18:00'), findsOneWidget); - // 无任何变更 → 保存禁用(PATCH minProperties 1)。 - expect( - tester.widget(find.byType(FilledButton)).onPressed, - isNull, - ); }); - testWidgets('编辑:仅拨动 originalDownloadEnabled → PATCH 单键载荷', + testWidgets('页面 10 编辑:原图下载受总下载约束(禁用 + PATCH 联动关断)', + (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums/alb-1' + ? (200, albumJson(downloadEnabled: false)) + : throw StateError('unexpected ${o.path}'), + ); + // 原相册:下载开、原图开。 + final existing = + Album.fromJson(albumJson(originalDownloadEnabled: true)); + await pumpForm(tester, clientOf(adapter), existing: existing); + + await openAccessTab(tester); + + // 关闭总下载 → 原图开关禁用且显示为关。 + await tester.tap(find.widgetWithText(SwitchListTile, 'Allow downloads')); + await tester.pump(); + final original = find.widgetWithText(SwitchListTile, 'Original downloads'); + expect(tester.widget(original).onChanged, isNull, + reason: '总下载关闭 → 原图开关禁用'); + expect(tester.widget(original).value, isFalse, + reason: '生效值恒为 false'); + + // 保存 → PATCH 联动关断原图(不允许「原图开而下载关」的矛盾态)。 + await tester.tap(find.text('Save changes')); + await tester.pumpAndSettle(); + + expect(adapter.requests.single.body, { + 'downloadEnabled': false, + 'originalDownloadEnabled': false, + }); + }); + + testWidgets('页面 10 编辑:仅拨动 originalDownloadEnabled → PATCH 单键载荷', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums/alb-1' @@ -272,13 +399,14 @@ void main() { final existing = Album.fromJson(albumJson()); await pumpForm(tester, clientOf(adapter), existing: existing); - await tapVisible( - tester, - find.widgetWithText(SwitchListTile, 'Allow original downloads'), - ); + await openAccessTab(tester); + await tester + .tap(find.widgetWithText(SwitchListTile, 'Original downloads')); await tester.pump(); - await tapVisible(tester, find.text('Save')); + await tester.ensureVisible(find.text('Save changes')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save changes')); await tester.pumpAndSettle(); final request = adapter.requests.single; @@ -289,7 +417,7 @@ void main() { }); }); - testWidgets('编辑:清除 endAt → PATCH 序列化 "endAt": null', (tester) async { + testWidgets('页面 10 编辑:清除 endAt → PATCH 序列化 "endAt": null', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums/alb-1' ? (200, albumJson(endAt: null)) @@ -303,13 +431,13 @@ void main() { await tapVisible(tester, find.byTooltip('Clear end time')); await tester.pump(); - await tapVisible(tester, find.text('Save')); + await tapVisible(tester, find.text('Save changes')); await tester.pumpAndSettle(); expect(adapter.requests.single.body, {'endAt': null}); }); - testWidgets('编辑:修改 zh 标题 → 仅发全量 translations(不含开关/时间)', + testWidgets('页面 10 编辑:修改 zh 标题 → 仅发全量 translations(不含开关/时间)', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums/alb-1' @@ -325,7 +453,7 @@ void main() { ); await tester.pump(); - await tapVisible(tester, find.text('Save')); + await tapVisible(tester, find.text('Save changes')); await tester.pumpAndSettle(); final body = adapter.requests.single.body as Map; @@ -347,4 +475,81 @@ void main() { }, ]); }); + + testWidgets('页面 10 编辑:未保存变更退出 → 放弃确认(继续编辑/放弃两路)', + (tester) async { + final adapter = FakeAdapter( + (o) => throw StateError('不应发起请求:${o.path}'), + ); + final client = clientOf(adapter); + + /// 以父路由承载(表单作为可弹出路由,AppBar 才有返回键)。 + Future pumpPushedForm() async { + await tester.pumpWidget( + ProviderScope( + overrides: [apiClientProvider.overrideWith((ref) => client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AlbumFormPage( + existing: Album.fromJson(albumJson()), + ), + ), + ), + child: const Text('open form'), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('open form')); + await tester.pumpAndSettle(); + } + + await pumpPushedForm(); + + // 无变更:系统返回直接弹出(无确认框)。 + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.byType(AlbumFormPage), findsNothing); + + // 重新进入并制造变更。 + await pumpPushedForm(); + await tester.enterText(find.byType(TextFormField).at(1), '闭幕演出'); + await tester.pump(); + + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.text('Discard changes?'), findsOneWidget); + expect( + find.text('Your edits are not saved yet. Leaving now will discard them.'), + findsOneWidget, + ); + + // 继续编辑 → 留在页面,输入保留。 + await tester.tap(find.text('Keep editing')); + await tester.pumpAndSettle(); + expect(find.byType(AlbumFormPage), findsOneWidget); + expect( + tester.widget(find.byType(TextFormField).at(1)).controller! + .text, + '闭幕演出', + ); + + // 放弃 → 弹出页面。 + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(find.byType(AlbumFormPage), findsNothing); + }); } diff --git a/apps/mobile/test/pages/album_members_page_test.dart b/apps/mobile/test/pages/album_members_page_test.dart index ef1854a..2440430 100644 --- a/apps/mobile/test/pages/album_members_page_test.dart +++ b/apps/mobile/test/pages/album_members_page_test.dart @@ -1,8 +1,9 @@ -/// 成员管理页 widget 测试(P3 计划 Task 4)。 +/// 成员管理页 widget 测试(UI-03 §4 页面 11 重设计后)。 /// -/// 覆盖:列表角色标签/owner 徽标/本人与创建者不可移除;账号前缀搜索 -/// 防抖(300ms、≥2 字符、连续输入合并);邀请载荷与 MEMBER_CONFLICT -/// 本地化;移除确认流。 +/// 覆盖:Owner 单独分组(无移除)与 Collaborators 列表(首字母头像/ +/// 显示名/账号·角色/行尾「⋯」)、标题计数、空协作者态;Invite colleague +/// BottomSheet 搜索(防抖 300ms、≥2 字符、连续输入合并)→ Add 载荷 → +/// 已添加态;MEMBER_CONFLICT 本地化;移除确认(含成员名)→ DELETE。 library; import 'package:dio/dio.dart'; @@ -21,6 +22,7 @@ import '../helpers/fakes.dart'; void main() { late FakeSessionStore store; late List> members; + late List> lookups; setUp(() { store = FakeSessionStore() @@ -39,6 +41,7 @@ void main() { memberJson(userId: 'u-1', account: 'staff01', displayName: 'Staff One'), memberJson(userId: 'u-2', account: 'staff02', displayName: 'Staff Two'), ]; + lookups = >[lookupUserJson()]; }); FakeAdapter membersAdapter({ @@ -66,9 +69,7 @@ void main() { return (204, null); } if (o.path == '/v1/admin/users' && o.method == 'GET') { - return (200, { - 'items': >[lookupUserJson()], - }); + return (200, {'items': lookups}); } throw StateError('unexpected ${o.method} ${o.path}'); }); @@ -99,25 +100,58 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('列表渲染:角色标签、owner 徽标、本人/创建者无移除按钮', + /// 打开邀请面板(底部全宽按钮)。 + Future openInviteSheet(WidgetTester tester) async { + await tester.tap(find.text('Invite colleague')); + await tester.pumpAndSettle(); + expect(find.byType(AlbumInviteSheet), findsOneWidget); + } + + testWidgets('分组渲染:Owner 单独组(无「⋯」)、协作者行尾「⋯」、标题计数', (tester) async { await pumpMembers(tester, clientOf(membersAdapter())); + expect(find.text('Members (3)'), findsOneWidget); expect(find.text('Owner'), findsOneWidget); - expect(find.text('Collaborator'), findsNWidgets(2)); - expect(find.byIcon(Icons.star), findsOneWidget, reason: 'owner 徽标'); - // 本人标记。 - expect(find.text('staff01 (you)'), findsOneWidget); - // 仅 staff02(非 owner、非本人)有移除按钮。 - expect(find.byIcon(Icons.person_remove_outlined), findsOneWidget); + expect(find.text('Collaborators'), findsOneWidget); + // Owner 行:显示名 + 账号·角色,无移除入口。 + expect(find.text('The Owner'), findsOneWidget); + expect(find.text('boss01 · Owner'), findsOneWidget); + // 协作者行:staff01 为本人(带标记),staff02 可移除(行尾「⋯」)。 + expect(find.text('Staff One (you)'), findsOneWidget); + expect(find.text('staff01 · Collaborator'), findsOneWidget); + expect(find.text('Staff Two'), findsOneWidget); + expect(find.byIcon(Icons.more_horiz), findsOneWidget, + reason: '仅非本人协作者有「⋯」'); + // 首字母头像。 + expect(find.text('T'), findsOneWidget); + expect(find.text('S'), findsNWidgets(2)); }); - testWidgets('搜索防抖:<2 字符不请求;连续输入合并为最后一次(300ms)', + testWidgets('空协作者:占位文案 + 底部邀请入口', (tester) async { + members = >[ + memberJson( + userId: 'u-owner', + account: 'boss01', + displayName: 'The Owner', + role: 'owner', + ), + ]; + await pumpMembers(tester, clientOf(membersAdapter())); + + expect(find.text('Members (1)'), findsOneWidget); + expect(find.text('No collaborators yet'), findsOneWidget); + expect(find.byIcon(Icons.person_add_outlined), findsOneWidget); + }); + + testWidgets('邀请搜索防抖:<2 字符不请求;连续输入合并为最后一次(300ms)', (tester) async { final adapter = membersAdapter(); await pumpMembers(tester, clientOf(adapter)); + await openInviteSheet(tester); final field = find.byType(TextField); + // 1 个字符:无请求。 await tester.enterText(field, 's'); await tester.pump(const Duration(milliseconds: 400)); @@ -132,25 +166,42 @@ void main() { await tester.enterText(field, 'sta'); await tester.pump(const Duration(milliseconds: 350)); - final lookups = adapter.requests + final lookupsRequests = adapter.requests .where((r) => r.path == '/v1/admin/users') .toList(); - expect(lookups, hasLength(1)); - expect(lookups.single.uri.queryParameters['accountPrefix'], 'sta'); + expect(lookupsRequests, hasLength(1)); + expect(lookupsRequests.single.uri.queryParameters['accountPrefix'], 'sta'); }); - testWidgets('搜索命中 → 点击邀请 → POST userId 载荷 → 列表刷新 + 提示', + testWidgets('邀请流:搜索命中 → Add → POST userId 载荷 → 已添加态 + 列表刷新', (tester) async { + // 命中结果同时含已成员(staff02)与新用户(staff09)。 + lookups = >[ + lookupUserJson(), + lookupUserJson( + id: 'u-2', + account: 'staff02', + displayName: 'Staff Two', + ), + ]; final adapter = membersAdapter(); await pumpMembers(tester, clientOf(adapter)); - await tester.enterText(find.byType(TextField), 'staff09'); + await openInviteSheet(tester); + await tester.enterText(find.byType(TextField), 'staff'); await tester.pump(const Duration(milliseconds: 400)); - // 搜索框 EditableText 与结果条目同名,按 ListTile 定位结果。 - expect(find.widgetWithText(ListTile, 'staff09'), findsOneWidget); + // 已成员(staff02):显示 Already added,无 Add 按钮(结果行限定在 + // 面板内——页面成员列表同名行在面板后面)。 + final sheet = find.byType(AlbumInviteSheet); + expect(find.text('Already added'), findsOneWidget); + expect( + find.descendant(of: sheet, matching: find.text('Staff Two')), + findsOneWidget, + ); - await tester.tap(find.text('Invite')); + // 新用户:Add → POST {'userId': 'u-9'} → 面板内即时变为已添加。 + await tester.tap(find.text('Add')); await tester.pumpAndSettle(); final invite = adapter.requests.singleWhere( @@ -158,8 +209,10 @@ void main() { ); expect(invite.body, {'userId': 'u-9'}); expect(find.text('Invited'), findsOneWidget); - // 邀请成功后列表重载(staff09 入列)。 - expect(find.text('Collaborator'), findsNWidgets(3)); + expect(find.text('Already added'), findsNWidgets(2), + reason: '邀请成功后该行即时变为已添加'); + // 上级列表已刷新(staff09 入列)。 + expect(find.text('staff09 · Collaborator'), findsOneWidget); }); testWidgets('邀请冲突(409 MEMBER_CONFLICT)→ 已是成员文案', (tester) async { @@ -169,22 +222,25 @@ void main() { ); await pumpMembers(tester, clientOf(adapter)); + await openInviteSheet(tester); await tester.enterText(find.byType(TextField), 'staff09'); await tester.pump(const Duration(milliseconds: 400)); - await tester.tap(find.text('Invite')); + await tester.tap(find.text('Add')); await tester.pumpAndSettle(); expect(find.text('User is already a member'), findsOneWidget); }); - testWidgets('移除成员:确认对话框门控 → DELETE → 列表刷新', (tester) async { + testWidgets('移除成员:「⋯」→ 确认(含成员名)→ DELETE → 列表刷新', + (tester) async { final adapter = membersAdapter(); await pumpMembers(tester, clientOf(adapter)); - await tester.tap(find.byIcon(Icons.person_remove_outlined)); + await tester.tap(find.byIcon(Icons.more_horiz)); await tester.pumpAndSettle(); - expect(find.text('Remove this member?'), findsOneWidget); + // 确认文案带成员名。 + expect(find.text('Remove Staff Two from this album?'), findsOneWidget); // 取消不发请求。 await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); @@ -193,7 +249,7 @@ void main() { isFalse, ); - await tester.tap(find.byIcon(Icons.person_remove_outlined)); + await tester.tap(find.byIcon(Icons.more_horiz)); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Delete')); await tester.pumpAndSettle(); @@ -204,6 +260,7 @@ void main() { ), isTrue, ); - expect(find.text('staff02'), findsNothing); + expect(find.text('Staff Two'), findsNothing); + expect(find.text('Members (2)'), findsOneWidget); }); }