feat(mobile): UI-03 forms and collaboration pages (09-12)
- Page 09 create: grouped form (Event details / Description & languages), required default-language selector, zh-CN/en tabs with per-locale title/description/venue/address, explicit timezone field, bottom save button kept keyboard-reachable, create mode has no Access tab - Page 10 edit: Details/Access tabs sharing one draft; Access has four switches with explanations; original downloads constrained by downloads (disabled + forced off in PATCH); unsaved-changes PopScope confirm; save commits all changes in one minimal PATCH - Page 11 members: Owner group + Collaborators list with initial avatars and per-row remove; full-width Invite colleague opens account-search bottom sheet (debounced prefix search, race guard, already-added state); remove confirm names the member - Page 12 categories: extracted to standalone page with per-row edit/ delete menu and New category sheet (language tabs + name + sort order); delete confirm mentions photo impact; collaborator browse-only; old inline section removed from album detail - l10n: new form/members/categories keys both locales; regenerated - tests: form 14, members 6, categories 7, detail nav 1; full suite green Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 => '创建者';
|
||||
|
||||
@@ -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": "(我)",
|
||||
|
||||
@@ -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<AlbumCategoriesPage> createState() =>
|
||||
_AlbumCategoriesPageState();
|
||||
}
|
||||
|
||||
class _AlbumCategoriesPageState extends ConsumerState<AlbumCategoriesPage> {
|
||||
late Album _album;
|
||||
bool _reloading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_album = widget.album;
|
||||
}
|
||||
|
||||
/// CRUD 后重取相册(categories 随详情返回),保持单一数据源。
|
||||
Future<void> _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<void> _openEditor([Category? existing]) async {
|
||||
final changed = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (_) => CategoryEditSheet(
|
||||
albumId: _album.id,
|
||||
defaultLocale: _album.defaultLocale,
|
||||
existing: existing,
|
||||
),
|
||||
);
|
||||
if (changed == true) await _reload();
|
||||
}
|
||||
|
||||
// ── 删除(确认说明照片影响) ────────────────────────────────────
|
||||
|
||||
Future<void> _delete(Category category) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<String>(
|
||||
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<CategoryEditSheet> createState() => _CategoryEditSheetState();
|
||||
}
|
||||
|
||||
class _CategoryEditSheetState extends ConsumerState<CategoryEditSheet>
|
||||
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<void> _submit() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final zh = _zhName.text.trim();
|
||||
final en = _enName.text.trim();
|
||||
if (zh.isEmpty && en.isEmpty) {
|
||||
setState(() => _error = l10n.categoryNameRequired);
|
||||
return;
|
||||
}
|
||||
final sortOrder = int.tryParse(_sortOrder.text.trim());
|
||||
if (sortOrder == null) {
|
||||
setState(() => _error = l10n.categorySortOrderInvalid);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = null;
|
||||
_submitting = true;
|
||||
});
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
final translations = <CategoryTranslation>[
|
||||
if (zh.isNotEmpty) CategoryTranslation(locale: _zh, 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<AlbumDetailPage> {
|
||||
final GlobalKey _categoriesKey = GlobalKey();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
Album? _album;
|
||||
bool _loading = true;
|
||||
String? _errorCode;
|
||||
@@ -75,12 +74,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
@@ -210,6 +203,19 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
await _loadPhotoPreview();
|
||||
}
|
||||
|
||||
/// 分类管理(页面 12):进入独立分类页;返回后重载详情(分类随详情
|
||||
/// 返回,供连拍目标等使用)。
|
||||
Future<void> _openCategories() async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
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<AlbumDetailPage> {
|
||||
case AlbumAction.members:
|
||||
await _openMembers();
|
||||
case AlbumAction.categories:
|
||||
_scrollToCategories();
|
||||
await _openCategories();
|
||||
case AlbumAction.share:
|
||||
_shareQr();
|
||||
}
|
||||
@@ -294,64 +300,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _scrollToCategories() {
|
||||
final ctx = _categoriesKey.currentContext;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteCategory(Category category) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(l10n.delete),
|
||||
content: Text(l10n.deleteConfirmCategory),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(l10n.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.deleteCategory(category.id);
|
||||
await _load();
|
||||
} on AppException catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.messageForError(e))),
|
||||
);
|
||||
} on Object {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.errorGeneric)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openCategoryDialog([Category? existing]) async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
final changed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => CategoryEditDialog(albumId: album.id, existing: existing),
|
||||
);
|
||||
if (changed == true) await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
@@ -381,7 +329,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
onRetry: _load,
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -423,7 +370,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
isManager: _isManager,
|
||||
onPhotos: _openPhotos,
|
||||
onMembers: _openMembers,
|
||||
onCategories: _scrollToCategories,
|
||||
onCategories: _openCategories,
|
||||
onShare: _shareQr,
|
||||
),
|
||||
),
|
||||
@@ -441,21 +388,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
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<void> Function(Category category) onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.albumCategories,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.categoryAdd,
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (album.categories.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
l10n.categoryEmpty,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
else
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, category) in album.categories.indexed) ...[
|
||||
if (i > 0) const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: SizedBox(
|
||||
width: 40,
|
||||
child: Text(
|
||||
'${category.sortOrder}',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(_namesOf(category)),
|
||||
onTap: () => onEdit(category),
|
||||
trailing: IconButton(
|
||||
tooltip: l10n.delete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => onDelete(category),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 分类名展示:zh / en 名称以「 · 」连接。
|
||||
static String _namesOf(Category category) {
|
||||
final names = category.translations.map((t) => t.name).toList();
|
||||
return names.isEmpty ? category.id : names.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类新增/编辑对话框:排序序号 + 中文名 + 英文名(至少一个必填)。
|
||||
///
|
||||
/// 成功 pop(true);校验失败就地提示;API 失败 SnackBar(对话框保持)。
|
||||
class CategoryEditDialog extends ConsumerStatefulWidget {
|
||||
const CategoryEditDialog({
|
||||
required this.albumId,
|
||||
this.existing,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String albumId;
|
||||
|
||||
/// 编辑目标;null = 新增。
|
||||
final Category? existing;
|
||||
|
||||
@override
|
||||
ConsumerState<CategoryEditDialog> createState() => _CategoryEditDialogState();
|
||||
}
|
||||
|
||||
class _CategoryEditDialogState extends ConsumerState<CategoryEditDialog> {
|
||||
late final TextEditingController _sortOrder =
|
||||
TextEditingController(text: (widget.existing?.sortOrder ?? 0).toString());
|
||||
late final TextEditingController _zhName = TextEditingController(
|
||||
text: widget.existing?.translations
|
||||
.where((t) => t.locale == 'zh-CN')
|
||||
.firstOrNull
|
||||
?.name ??
|
||||
'',
|
||||
);
|
||||
late final TextEditingController _enName = TextEditingController(
|
||||
text: widget.existing?.translations
|
||||
.where((t) => t.locale == 'en')
|
||||
.firstOrNull
|
||||
?.name ??
|
||||
'',
|
||||
);
|
||||
|
||||
String? _error;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sortOrder.dispose();
|
||||
_zhName.dispose();
|
||||
_enName.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final zh = _zhName.text.trim();
|
||||
final en = _enName.text.trim();
|
||||
if (zh.isEmpty && en.isEmpty) {
|
||||
setState(() => _error = l10n.categoryNameRequired);
|
||||
return;
|
||||
}
|
||||
final sortOrder = int.tryParse(_sortOrder.text.trim());
|
||||
if (sortOrder == null) {
|
||||
setState(() => _error = l10n.categorySortOrderInvalid);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = null;
|
||||
_submitting = true;
|
||||
});
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
final translations = <CategoryTranslation>[
|
||||
if (zh.isNotEmpty) CategoryTranslation(locale: 'zh-CN', name: zh),
|
||||
if (en.isNotEmpty) CategoryTranslation(locale: 'en', name: en),
|
||||
];
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
if (widget.existing == null) {
|
||||
await api.createCategory(
|
||||
widget.albumId,
|
||||
CategoryCreateRequest(
|
||||
sortOrder: sortOrder,
|
||||
translations: translations,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await api.updateCategory(
|
||||
widget.existing!.id,
|
||||
CategoryUpdateRequest(
|
||||
sortOrder: sortOrder,
|
||||
translations: translations,
|
||||
),
|
||||
);
|
||||
}
|
||||
navigator.pop(true);
|
||||
} on AppException catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.messageForError(e))),
|
||||
);
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
} on Object {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.errorGeneric)),
|
||||
);
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(widget.existing == null ? l10n.categoryAdd : l10n.categoryEdit),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _sortOrder,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.categorySortOrder,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _zhName,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.categoryNameZh,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _enName,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.categoryNameEn,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(l10n.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AlbumFormPage> createState() => _AlbumFormPageState();
|
||||
}
|
||||
|
||||
class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
with TickerProviderStateMixin {
|
||||
static const String _zh = 'zh-CN';
|
||||
static const String _en = 'en';
|
||||
|
||||
@@ -60,7 +68,17 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
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<AlbumFormError> _errors = const <AlbumFormError>[];
|
||||
bool _submitting = false;
|
||||
|
||||
@@ -78,6 +96,7 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
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<AlbumFormPage> {
|
||||
_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<AlbumFormPage> {
|
||||
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<AlbumFormPage> {
|
||||
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<AlbumFormPage> {
|
||||
}
|
||||
navigator.pop(true);
|
||||
} on AppException catch (error) {
|
||||
// 保存失败:保留输入,仅提示。
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.messageForError(error))),
|
||||
);
|
||||
@@ -186,6 +250,31 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 未保存变更 → 放弃确认;确认后直接 pop(绕过 PopScope 拦截)。
|
||||
Future<void> _confirmDiscard() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final discard = await showDialog<bool>(
|
||||
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<void> _pickDateTime({required bool isStart}) async {
|
||||
final tzName = _timezone.text.trim();
|
||||
@@ -223,17 +312,33 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
@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<AlbumFormPage> {
|
||||
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<String>(
|
||||
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<String>(
|
||||
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,
|
||||
|
||||
@@ -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<AlbumMembersPage> {
|
||||
/// 当前登录账号(本人移除 UI 拦截用);读取失败按未知处理。
|
||||
String? _selfAccount;
|
||||
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _debounce;
|
||||
bool _searching = false;
|
||||
List<AdminUserLookupItem>? _results;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -50,13 +50,6 @@ class _AlbumMembersPageState extends ConsumerState<AlbumMembersPage> {
|
||||
_reload();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadSelfAccount() async {
|
||||
try {
|
||||
final session = await ref.read(sessionStoreProvider).read();
|
||||
@@ -138,7 +131,281 @@ class _AlbumMembersPageState extends ConsumerState<AlbumMembersPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 邀请搜索(防抖 300ms、≥2 字符) ──
|
||||
// ── 邀请(BottomSheet 搜索选择器) ─────────────────────────────
|
||||
|
||||
Future<void> _openInviteSheet() async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (_) => AlbumInviteSheet(
|
||||
albumId: widget.album.id,
|
||||
// 打开时的既有成员(已添加态判定;面板内邀请成功后本地累加)。
|
||||
memberUserIds: <String>{
|
||||
for (final m in _items) m.userId,
|
||||
},
|
||||
memberAccounts: <String>{
|
||||
for (final m in _items) m.account,
|
||||
},
|
||||
onInvited: _reload,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 移除(行尾「⋯」→ 确认对话框) ─────────────────────────────
|
||||
|
||||
Future<void> _remove(Member member) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final name = member.displayName.trim().isNotEmpty
|
||||
? member.displayName.trim()
|
||||
: member.account;
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<Member> owners,
|
||||
List<Member> 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<String> memberUserIds;
|
||||
|
||||
/// 打开面板时的成员账号集合(按账号兜底判重)。
|
||||
final Set<String> memberAccounts;
|
||||
|
||||
/// 邀请成功回调(上级刷新成员列表)。
|
||||
final VoidCallback onInvited;
|
||||
|
||||
@override
|
||||
ConsumerState<AlbumInviteSheet> createState() => _AlbumInviteSheetState();
|
||||
}
|
||||
|
||||
class _AlbumInviteSheetState extends ConsumerState<AlbumInviteSheet> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _debounce;
|
||||
bool _searching = false;
|
||||
List<AdminUserLookupItem>? _results;
|
||||
|
||||
/// 面板生命周期内新增的成员(已添加态即时生效)。
|
||||
final Set<String> _addedIds = <String>{};
|
||||
|
||||
@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<AlbumMembersPage> {
|
||||
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<AlbumMembersPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _remove(Member member) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<AlbumMembersPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
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<void> 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<Map<String, dynamic>> categories;
|
||||
|
||||
setUp(() {
|
||||
store = FakeSessionStore();
|
||||
categories = <Map<String, dynamic>>[
|
||||
categoryJson(id: 'cat-1', sortOrder: 0),
|
||||
categoryJson(
|
||||
id: 'cat-2',
|
||||
sortOrder: 1,
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '展厅'},
|
||||
<String, dynamic>{'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<void> pumpCategories(
|
||||
WidgetTester tester,
|
||||
ApiClient client, {
|
||||
bool isManager = true,
|
||||
List<Map<String, dynamic>>? 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 = <Map<String, dynamic>>[
|
||||
...categories,
|
||||
categoryJson(
|
||||
id: 'cat-new',
|
||||
sortOrder: 5,
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
|
||||
<String, dynamic>{'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, <String, dynamic>{
|
||||
'sortOrder': 5,
|
||||
'translations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
|
||||
<String, dynamic>{'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 = <Map<String, dynamic>>[
|
||||
categoryJson(
|
||||
id: 'cat-1',
|
||||
sortOrder: 2,
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
|
||||
<String, dynamic>{'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<TextFormField>(
|
||||
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, <String, dynamic>{
|
||||
'sortOrder': 2,
|
||||
'translations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
|
||||
<String, dynamic>{'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: <Map<String, dynamic>>[],
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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 = <Map<String, dynamic>>[
|
||||
...categories,
|
||||
categoryJson(
|
||||
id: 'cat-new',
|
||||
sortOrder: 5,
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
|
||||
<String, dynamic>{'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, <String, dynamic>{
|
||||
'sortOrder': 5,
|
||||
'translations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
|
||||
<String, dynamic>{'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 = <Map<String, dynamic>>[
|
||||
categoryJson(
|
||||
id: 'cat-1',
|
||||
sortOrder: 2,
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
|
||||
<String, dynamic>{'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<TextFormField>(
|
||||
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, <String, dynamic>{
|
||||
'sortOrder': 2,
|
||||
'translations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
|
||||
],
|
||||
});
|
||||
expect(find.text('签到墙 · Ceremony'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('删除分类:确认对话框 → DELETE → 列表移除', (tester) async {
|
||||
final adapter = detailAdapter(
|
||||
onCategoryMutation: (method, path) {
|
||||
if (method == 'DELETE') {
|
||||
categories = <Map<String, dynamic>>[];
|
||||
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<String> galleryPaths;
|
||||
|
||||
@@ -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<void> 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<String>), 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<TextFormField>(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<TextFormField>(
|
||||
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<FilledButton>(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<TextFormField>(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<SwitchListTile>(
|
||||
find.widgetWithText(SwitchListTile, 'Publicly visible'),
|
||||
)
|
||||
.value,
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<SwitchListTile>(
|
||||
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<FilledButton>(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<SwitchListTile>(original).onChanged, isNull,
|
||||
reason: '总下载关闭 → 原图开关禁用');
|
||||
expect(tester.widget<SwitchListTile>(original).value, isFalse,
|
||||
reason: '生效值恒为 false');
|
||||
|
||||
// 保存 → PATCH 联动关断原图(不允许「原图开而下载关」的矛盾态)。
|
||||
await tester.tap(find.text('Save changes'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.requests.single.body, <String, dynamic>{
|
||||
'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, <String, dynamic>{'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<String, dynamic>;
|
||||
@@ -347,4 +475,81 @@ void main() {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('页面 10 编辑:未保存变更退出 → 放弃确认(继续编辑/放弃两路)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => throw StateError('不应发起请求:${o.path}'),
|
||||
);
|
||||
final client = clientOf(adapter);
|
||||
|
||||
/// 以父路由承载(表单作为可弹出路由,AppBar 才有返回键)。
|
||||
Future<void> 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<void>(
|
||||
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<TextFormField>(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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Map<String, dynamic>> members;
|
||||
late List<Map<String, dynamic>> 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 = <Map<String, dynamic>>[lookupUserJson()];
|
||||
});
|
||||
|
||||
FakeAdapter membersAdapter({
|
||||
@@ -66,9 +69,7 @@ void main() {
|
||||
return (204, null);
|
||||
}
|
||||
if (o.path == '/v1/admin/users' && o.method == 'GET') {
|
||||
return (200, <String, dynamic>{
|
||||
'items': <Map<String, dynamic>>[lookupUserJson()],
|
||||
});
|
||||
return (200, <String, dynamic>{'items': lookups});
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
@@ -99,25 +100,58 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('列表渲染:角色标签、owner 徽标、本人/创建者无移除按钮',
|
||||
/// 打开邀请面板(底部全宽按钮)。
|
||||
Future<void> 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 = <Map<String, dynamic>>[
|
||||
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 = <Map<String, dynamic>>[
|
||||
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, <String, dynamic>{'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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user