diff --git a/apps/mobile/lib/features/albums/album_form.dart b/apps/mobile/lib/features/albums/album_form.dart new file mode 100644 index 0000000..e778641 --- /dev/null +++ b/apps/mobile/lib/features/albums/album_form.dart @@ -0,0 +1,284 @@ +/// 相册表单草稿/校验/载荷构建(P3 计划 Task 4)。 +/// +/// 镜像 admin Web `albumValidation.ts` 的客户端预检(服务端 +/// VALIDATION_FAILED 仍是权威): +/// - 默认语言条目必须存在且 title 非空(契约 translations minItems 1 + +/// defaultLocale 覆盖); +/// - 「某语言填了描述/场地/地址但 title 空」视为漏填; +/// - endAt 必须严格晚于 startAt(null=未定放行); +/// - 时区必须是非空合法 IANA 名。 +/// +/// PATCH 载荷最小化:[toUpdatePatch] 仅提交与原相册不同的字段——四个 +/// 开关的「独立性」由此保证(未拨动的开关不出现在请求体),translations +/// 全量替换(有任一语言内容变更即整组提交)。 +library; + +import 'album_format.dart'; +import 'models.dart'; + +/// 表单错误码(展示层映射 l10n 文案)。 +enum AlbumFormError { + /// 默认语言条目缺失或标题为空。 + defaultLocaleMissing, + + /// 某语言填了其他字段但没填标题。 + titleRequiredWithFields, + + /// 未选择开始时间。 + startAtRequired, + + /// 结束时间不晚于开始时间。 + endAtNotAfterStartAt, + + /// 时区为空或不是合法 IANA 名。 + timezoneInvalid, +} + +/// 单语言表单草稿(zh-CN / en 两个 tab 各一份;空串 = 未填)。 +class AlbumTranslationDraft { + const AlbumTranslationDraft({ + required this.locale, + this.title = '', + this.description = '', + this.venue = '', + this.address = '', + }); + + /// 从服务端条目预填(null 可选字段归一为空串)。 + factory AlbumTranslationDraft.fromTranslation(AlbumTranslation t) => + AlbumTranslationDraft( + locale: t.locale, + title: t.title, + description: t.description ?? '', + venue: t.venue ?? '', + address: t.address ?? '', + ); + + /// 语言(zh-CN | en)。 + final String locale; + + /// 标题(默认语言必填)。 + final String title; + + /// 描述;空串 = 未填。 + final String description; + + /// 场地名称;空串 = 未填。 + final String venue; + + /// 场地地址;空串 = 未填。 + final String address; + + /// 是否填了除标题以外的任一字段。 + bool get hasOtherFields => + description.trim().isNotEmpty || + venue.trim().isNotEmpty || + address.trim().isNotEmpty; +} + +/// 相册表单草稿(创建/编辑共用;UI 层持可变控件,提交前汇成本类走纯逻辑)。 +class AlbumFormDraft { + const AlbumFormDraft({ + required this.zh, + required this.en, + required this.defaultLocale, + required this.timezone, + this.startAt, + this.endAt, + this.uploadEnabled = true, + this.publicEnabled = true, + this.downloadEnabled = true, + this.originalDownloadEnabled = false, + }); + + /// 从既有相册预填(编辑模式)。 + factory AlbumFormDraft.fromAlbum(Album album) => AlbumFormDraft( + zh: _draftFor(album, 'zh-CN'), + en: _draftFor(album, 'en'), + defaultLocale: album.defaultLocale, + timezone: album.timezone, + startAt: album.startAt, + endAt: album.endAt, + uploadEnabled: album.uploadEnabled, + publicEnabled: album.publicEnabled, + downloadEnabled: album.downloadEnabled, + originalDownloadEnabled: album.originalDownloadEnabled, + ); + + static AlbumTranslationDraft _draftFor(Album album, String locale) { + for (final t in album.translations) { + if (t.locale == locale) return AlbumTranslationDraft.fromTranslation(t); + } + return AlbumTranslationDraft(locale: locale); + } + + /// 中文条目草稿。 + final AlbumTranslationDraft zh; + + /// 英文条目草稿。 + final AlbumTranslationDraft en; + + /// 默认语言(zh-CN | en)。 + final String defaultLocale; + + /// IANA 时区(表单缺省 Asia/Shanghai)。 + final String timezone; + + /// 开始时间(UTC 瞬间);null = 未选。 + final DateTime? startAt; + + /// 结束时间(UTC 瞬间);null = 未定。 + final DateTime? endAt; + + /// 是否允许新上传任务(仅编辑可改)。 + final bool uploadEnabled; + + /// 是否公开可见(仅编辑可改)。 + final bool publicEnabled; + + /// 允许下载浏览版本。 + final bool downloadEnabled; + + /// 允许下载原图。 + final bool originalDownloadEnabled; + + /// 两个语言的草稿(zh-CN 在前,与 tab 顺序一致)。 + List get drafts => [zh, en]; + + /// 客户端预检;返回按序去重的错误码(空列表 = 通过)。 + List validate() { + final errors = {}; + final defaultDraft = drafts + .where((d) => d.locale == defaultLocale) + .toList(); + if (defaultDraft.isEmpty || defaultDraft.first.title.trim().isEmpty) { + errors.add(AlbumFormError.defaultLocaleMissing); + } + if (startAt == null) { + errors.add(AlbumFormError.startAtRequired); + } + for (final d in drafts) { + if (d.title.trim().isEmpty && d.hasOtherFields) { + errors.add(AlbumFormError.titleRequiredWithFields); + } + } + if (endAt != null && + startAt != null && + !endAt!.isAfter(startAt!)) { + errors.add(AlbumFormError.endAtNotAfterStartAt); + } + if (!isValidTimezone(timezone)) { + errors.add(AlbumFormError.timezoneInvalid); + } + return errors.toList(); + } + + /// 提炼 translations 载荷:只保留标题非空的语言条目,空白字段归一 + /// null(镜像 admin toTranslationPayload,避免空串占位覆盖 null 语义)。 + List toTranslationPayload() => drafts + .where((d) => d.title.trim().isNotEmpty) + .map( + (d) => AlbumTranslation( + locale: d.locale, + title: d.title.trim(), + description: _orNull(d.description), + venue: _orNull(d.venue), + address: _orNull(d.address), + ), + ) + .toList(); + + /// 创建请求体(调用前须已通过 [validate],startAt 非空)。 + /// + /// 契约缺省 uploadEnabled/publicEnabled=true 不可在创建时覆盖, + /// 请求体不含这两个开关([AlbumCreateRequest] 亦无对应字段)。 + AlbumCreateRequest toCreateRequest() => AlbumCreateRequest( + translations: toTranslationPayload(), + defaultLocale: defaultLocale, + startAt: startAt!, + endAt: endAt, + timezone: timezone.trim(), + downloadEnabled: downloadEnabled, + originalDownloadEnabled: originalDownloadEnabled, + ); + + /// 编辑载荷(最小 PATCH):仅提交与 [original] 不同的字段。 + /// + /// 无任何变更时返回 null(调用方据此免提交/禁用保存)。 + AlbumUpdateRequest? toUpdatePatch(Album original) { + final tzName = timezone.trim(); + final translations = toTranslationPayload(); + final translationsChanged = + !_sameTranslations(translations, original.translations); + final localeChanged = defaultLocale != original.defaultLocale; + final startChanged = + startAt != null && !_sameInstant(startAt!, original.startAt); + final endCleared = endAt == null && original.endAt != null; + final endChanged = endAt != null && + (original.endAt == null || !_sameInstant(endAt!, original.endAt!)); + final tzChanged = tzName != original.timezone; + final uploadChanged = uploadEnabled != original.uploadEnabled; + final publicChanged = publicEnabled != original.publicEnabled; + final downloadChanged = downloadEnabled != original.downloadEnabled; + final originalChanged = + originalDownloadEnabled != original.originalDownloadEnabled; + + final touched = + translationsChanged || + localeChanged || + startChanged || + endCleared || + endChanged || + tzChanged || + uploadChanged || + publicChanged || + downloadChanged || + originalChanged; + if (!touched) return null; + + return AlbumUpdateRequest( + translations: translationsChanged ? translations : null, + defaultLocale: localeChanged ? defaultLocale : null, + startAt: startChanged ? startAt : null, + endAt: endChanged ? endAt : null, + clearEndAt: endCleared, + timezone: tzChanged ? tzName : null, + uploadEnabled: uploadChanged ? uploadEnabled : null, + publicEnabled: publicChanged ? publicEnabled : null, + downloadEnabled: downloadChanged ? downloadEnabled : null, + originalDownloadEnabled: originalChanged ? originalDownloadEnabled : null, + ); + } + + static String? _orNull(String value) => + value.trim().isNotEmpty ? value.trim() : null; + + /// 瞬间相等(DateTime.== 额外比较 isUtc 标记,此处只比时间轴)。 + static bool _sameInstant(DateTime a, DateTime b) => + a.microsecondsSinceEpoch == b.microsecondsSinceEpoch; + + /// translations 组是否等价(按 locale 对齐,字段 trim 后比较; + /// null 与空串视为相等)。 + static bool _sameTranslations( + List a, + List b, + ) { + if (a.length != b.length) return false; + final byLocale = { + for (final t in b) t.locale: t, + }; + for (final t in a) { + final o = byLocale[t.locale]; + if (o == null) return false; + if (!_eq(t.title, o.title) || + !_eq(t.description, o.description) || + !_eq(t.venue, o.venue) || + !_eq(t.address, o.address)) { + return false; + } + } + return true; + } + + static bool _eq(String? a, String? b) => a?.trim() == b?.trim(); +} diff --git a/apps/mobile/lib/features/albums/album_format.dart b/apps/mobile/lib/features/albums/album_format.dart new file mode 100644 index 0000000..0fce643 --- /dev/null +++ b/apps/mobile/lib/features/albums/album_format.dart @@ -0,0 +1,86 @@ +/// 相册时区感知的时间展示(P3 计划 Task 4)。 +/// +/// 契约:startAt/endAt 按 UTC 存储,「展示按 timezone」。Dart 核心库无 +/// IANA 时区数据库,引入 `timezone` 包(内嵌 tzdata)做墙钟换算,字符串 +/// 格式化仍走 intl [DateFormat](App 语言)。tzdata 初始化为幂等懒加载 +/// ([ensureTzInitialized]),生产与测试共用同一路径。 +library; + +import 'package:intl/intl.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:timezone/timezone.dart' as tz; + +bool _tzInitialized = false; + +/// 初始化内嵌 tzdata(幂等;首次调用后为空操作)。 +void ensureTzInitialized() { + if (!_tzInitialized) { + tzdata.initializeTimeZones(); + _tzInitialized = true; + } +} + +/// 解析 IANA 时区名;无效返回 null。 +/// +/// 内嵌 tzdata(10 年窗口)不含 `UTC` 顶名(仅 `Etc/UTC`),此处将 +/// `UTC` 归一化到 `Etc/UTC`,避免最常用的时区名被误判非法。 +tz.Location? _resolveLocation(String timeZone) { + ensureTzInitialized(); + final name = timeZone.trim(); + try { + return tz.getLocation(name == 'UTC' ? 'Etc/UTC' : name); + } on Object { + return null; + } +} + +/// UTC 瞬间 → 相册时区墙钟;IANA 名无效返回 null。 +tz.TZDateTime? tryToAlbumZone(DateTime instant, String timeZone) { + final location = _resolveLocation(timeZone); + if (location == null) return null; + return tz.TZDateTime.from(instant.toUtc(), location); +} + +/// 时区名是否为合法 IANA 标识(表单校验用)。 +bool isValidTimezone(String timeZone) => + timeZone.trim().isNotEmpty && + tryToAlbumZone(DateTime.utc(2026, 1, 1), timeZone) != null; + +/// 按相册时区格式化时间(`y/M/d HH:mm` 模式,App locale)。 +/// +/// 时区无效时回退 UTC 展示(表单校验另行拦截无效时区,此处仅兜底)。 +String formatAlbumDateTime( + DateTime utcInstant, + String timeZone, + String locale, +) { + final zoned = tryToAlbumZone(utcInstant, timeZone) ?? utcInstant.toUtc(); + return DateFormat('y/M/d HH:mm', locale).format(zoned); +} + +/// 相册墙钟(表单里选择的日期时间,设备本地时区标记的 naive 值)→ +/// UTC 瞬间(提交与回显往返一致)。 +/// +/// 时区无效时按字面值视为 UTC(校验先行拦截,避免设备时区偏移污染)。 +DateTime albumWallClockToUtc(DateTime wallClock, String timeZone) { + final location = _resolveLocation(timeZone); + if (location == null) { + return DateTime.utc( + wallClock.year, + wallClock.month, + wallClock.day, + wallClock.hour, + wallClock.minute, + ); + } + return tz + .TZDateTime( + location, + wallClock.year, + wallClock.month, + wallClock.day, + wallClock.hour, + wallClock.minute, + ) + .toUtc(); +} diff --git a/apps/mobile/lib/features/albums/providers.dart b/apps/mobile/lib/features/albums/providers.dart new file mode 100644 index 0000000..933952f --- /dev/null +++ b/apps/mobile/lib/features/albums/providers.dart @@ -0,0 +1,125 @@ +/// 相册列表 Riverpod 状态(P3 计划 Task 4)。 +/// +/// 首帧后自动拉首页(build 返回加载态,microtask 里触发首次加载, +/// 不在 build 内改 state);游标加载更多(失败保留已加载条目并提供 +/// 重试标记);下拉刷新重置到第一页。创建/编辑/删除成功后由调用方 +/// 调 [AlbumListNotifier.refresh] 同步。 +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api_client.dart'; +import '../../core/app_exception.dart'; +import '../../core/error_codes.dart'; +import 'models.dart'; + +/// 相册列表状态。 +class AlbumListState { + const AlbumListState({ + this.items = const [], + this.nextCursor, + this.hasMore = false, + this.loading = false, + this.loadingMore = false, + this.loadMoreFailed = false, + this.errorCode, + }); + + /// 已加载条目(跨页累积)。 + final List items; + + /// 下一页游标;null = 没有更多。 + final String? nextCursor; + + /// 是否还有下一页(与 [nextCursor] 成对)。 + final bool hasMore; + + /// 首次加载/刷新进行中。 + final bool loading; + + /// 加载更多进行中。 + final bool loadingMore; + + /// 最近一次加载更多失败(保留已加载条目,页脚显示重试)。 + final bool loadMoreFailed; + + /// 首屏/刷新失败的错误码([AppException.code]);null = 无错误。 + final String? errorCode; +} + +/// 相册列表 Notifier(游标分页状态机)。 +class AlbumListNotifier extends Notifier { + /// 每页条数(契约列表端点通用上限内取 20)。 + static const int pageSize = 20; + + @override + AlbumListState build() { + // microtask:确保不在 build 执行期内改 state。 + Future.microtask(refresh); + return const AlbumListState(loading: true); + } + + /// 重置到第一页(下拉刷新/数据变更后同步)。 + Future refresh() async { + state = const AlbumListState(loading: true); + try { + final page = await ref + .read(apiClientProvider) + .listAlbums(limit: pageSize); + state = AlbumListState( + items: page.items, + nextCursor: page.nextCursor, + hasMore: page.hasMore, + ); + } on AppException catch (e) { + state = AlbumListState(errorCode: e.code); + } on Object { + state = const AlbumListState(errorCode: ErrorCodes.internalError); + } + } + + /// 游标加载下一页(加载中/无更多时为空操作)。 + Future loadMore() async { + if (state.loading || state.loadingMore || !state.hasMore) return; + state = AlbumListState( + items: state.items, + nextCursor: state.nextCursor, + hasMore: true, + loadingMore: true, + ); + try { + final page = await ref.read(apiClientProvider).listAlbums( + cursor: state.nextCursor, + limit: pageSize, + ); + state = AlbumListState( + items: [...state.items, ...page.items], + nextCursor: page.nextCursor, + hasMore: page.hasMore, + ); + } on AppException catch (e) { + // 保留已加载条目:页脚给重试入口,错误码用于一次性提示语义。 + state = AlbumListState( + items: state.items, + nextCursor: state.nextCursor, + hasMore: true, + loadMoreFailed: true, + errorCode: e.code, + ); + } on Object { + state = AlbumListState( + items: state.items, + nextCursor: state.nextCursor, + hasMore: true, + loadMoreFailed: true, + errorCode: ErrorCodes.internalError, + ); + } + } +} + +/// 相册列表 Provider(首页相册 tab 消费)。 +final albumListProvider = + NotifierProvider( + AlbumListNotifier.new, +); diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 1b812de..c309fb1 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -36,5 +36,76 @@ "errorValidation": "Invalid input. Please check and retry", "errorNetwork": "Network error. Check your connection and retry", "errorNetworkTimeout": "Network timed out. Please retry", - "errorInternal": "Service temporarily unavailable. Please retry later" + "errorInternal": "Service temporarily unavailable. Please retry later", + "albumsEmpty": "No albums yet. Tap \"Create album\" to get started", + "loadMoreFailed": "Failed to load more. Tap to retry", + "albumCreate": "Create album", + "albumEdit": "Edit album", + "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", + "create": "Create", + "save": "Save", + "albumStatusActive": "Active", + "albumStatusOffline": "Offline", + "tagUpload": "Upload", + "tagPublic": "Public", + "tagDownload": "Download", + "tagOriginal": "Original", + "notSet": "Not set", + "actionEdit": "Edit", + "albumMembers": "Members", + "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?", + "roleOwner": "Owner", + "roleCollaborator": "Collaborator", + "selfMarker": " (you)", + "errorMemberConflict": "User is already a member", + "qrHint": "Scan the code or open the link to view", + "copyLink": "Copy link", + "copiedToClipboard": "Copied", + "photosTask9Placeholder": "Photo management is a placeholder; the full experience ships in Task 9", + "albumPhotosEmpty": "No photos yet", + "visibilityPublished": "Published", + "visibilityUnpublished": "Unpublished", + "visibilityHidden": "Hidden", + "visibilityDeleted": "Deleted", + "procPendingUpload": "Pending upload", + "procUploaded": "Uploaded", + "procProcessing": "Processing", + "procReady": "Ready", + "procProcessingFailed": "Processing failed" } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 3cfa148..8b99f6e 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -319,6 +319,432 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'服务暂时不可用,请稍后重试'** String get errorInternal; + + /// No description provided for @albumsEmpty. + /// + /// In zh, this message translates to: + /// **'暂无相册,点击「新建相册」开始'** + String get albumsEmpty; + + /// No description provided for @loadMoreFailed. + /// + /// In zh, this message translates to: + /// **'加载更多失败,点击重试'** + String get loadMoreFailed; + + /// No description provided for @albumCreate. + /// + /// In zh, this message translates to: + /// **'新建相册'** + String get albumCreate; + + /// No description provided for @albumEdit. + /// + /// In zh, this message translates to: + /// **'编辑相册'** + String get albumEdit; + + /// No description provided for @albumTitle. + /// + /// In zh, this message translates to: + /// **'标题'** + String get albumTitle; + + /// No description provided for @albumDescription. + /// + /// In zh, this message translates to: + /// **'描述'** + String get albumDescription; + + /// No description provided for @albumVenue. + /// + /// In zh, this message translates to: + /// **'场地'** + String get albumVenue; + + /// No description provided for @albumAddress. + /// + /// In zh, this message translates to: + /// **'地址'** + 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: + /// **'开始时间'** + String get albumStartAt; + + /// No description provided for @albumEndAtOptional. + /// + /// In zh, this message translates to: + /// **'结束时间(可选)'** + String get albumEndAtOptional; + + /// No description provided for @clearEndAt. + /// + /// In zh, this message translates to: + /// **'清除结束时间'** + String get clearEndAt; + + /// No description provided for @albumTimezone. + /// + /// In zh, this message translates to: + /// **'时区'** + String get albumTimezone; + + /// No description provided for @albumTimezoneInvalid. + /// + /// In zh, this message translates to: + /// **'无效的 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: + /// **'请选择开始时间'** + String get formStartAtRequired; + + /// No description provided for @formDefaultLocaleMissing. + /// + /// In zh, this message translates to: + /// **'默认语言必须填写标题'** + String get formDefaultLocaleMissing; + + /// No description provided for @formTitleRequiredWithFields. + /// + /// In zh, this message translates to: + /// **'填写了描述/场地/地址时,标题不能为空'** + String get formTitleRequiredWithFields; + + /// No description provided for @formEndAtNotAfterStartAt. + /// + /// In zh, this message translates to: + /// **'结束时间必须晚于开始时间'** + String get formEndAtNotAfterStartAt; + + /// No description provided for @create. + /// + /// In zh, this message translates to: + /// **'创建'** + String get create; + + /// No description provided for @save. + /// + /// In zh, this message translates to: + /// **'保存'** + String get save; + + /// No description provided for @albumStatusActive. + /// + /// In zh, this message translates to: + /// **'正常'** + String get albumStatusActive; + + /// No description provided for @albumStatusOffline. + /// + /// In zh, this message translates to: + /// **'下线'** + String get albumStatusOffline; + + /// No description provided for @tagUpload. + /// + /// In zh, this message translates to: + /// **'上传'** + String get tagUpload; + + /// No description provided for @tagPublic. + /// + /// In zh, this message translates to: + /// **'公开'** + String get tagPublic; + + /// No description provided for @tagDownload. + /// + /// In zh, this message translates to: + /// **'下载'** + String get tagDownload; + + /// No description provided for @tagOriginal. + /// + /// In zh, this message translates to: + /// **'原图'** + String get tagOriginal; + + /// No description provided for @notSet. + /// + /// In zh, this message translates to: + /// **'未定'** + String get notSet; + + /// No description provided for @actionEdit. + /// + /// In zh, this message translates to: + /// **'编辑'** + String get actionEdit; + + /// No description provided for @albumMembers. + /// + /// In zh, this message translates to: + /// **'成员管理'** + String get albumMembers; + + /// No description provided for @albumCategories. + /// + /// In zh, this message translates to: + /// **'分类管理'** + String get albumCategories; + + /// No description provided for @shareQrTitle. + /// + /// In zh, this message translates to: + /// **'分享二维码'** + String get shareQrTitle; + + /// No description provided for @albumPhotos. + /// + /// In zh, this message translates to: + /// **'照片管理'** + String get albumPhotos; + + /// No description provided for @delete. + /// + /// In zh, this message translates to: + /// **'删除'** + 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: + /// **'中文名称'** + String get categoryNameZh; + + /// No description provided for @categoryNameEn. + /// + /// In zh, this message translates to: + /// **'英文名称'** + String get categoryNameEn; + + /// No description provided for @categorySortOrder. + /// + /// In zh, this message translates to: + /// **'排序序号'** + String get categorySortOrder; + + /// No description provided for @categoryNameRequired. + /// + /// In zh, this message translates to: + /// **'至少填写一个语言的名称'** + String get categoryNameRequired; + + /// No description provided for @categorySortOrderInvalid. + /// + /// In zh, this message translates to: + /// **'排序序号必须是整数'** + 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: + /// **'输入账号前缀搜索邀请(至少 2 个字符)'** + String get inviteSearchHint; + + /// No description provided for @inviteNoResults. + /// + /// In zh, this message translates to: + /// **'未找到匹配用户'** + 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: + /// **'已邀请'** + String get memberInvited; + + /// No description provided for @removeMember. + /// + /// In zh, this message translates to: + /// **'移除成员'** + String get removeMember; + + /// No description provided for @removeMemberConfirm. + /// + /// In zh, this message translates to: + /// **'确定移除该成员?'** + String get removeMemberConfirm; + + /// No description provided for @roleOwner. + /// + /// In zh, this message translates to: + /// **'创建者'** + String get roleOwner; + + /// No description provided for @roleCollaborator. + /// + /// In zh, this message translates to: + /// **'协作者'** + String get roleCollaborator; + + /// No description provided for @selfMarker. + /// + /// In zh, this message translates to: + /// **'(我)'** + String get selfMarker; + + /// No description provided for @errorMemberConflict. + /// + /// In zh, this message translates to: + /// **'该用户已是相册成员'** + String get errorMemberConflict; + + /// No description provided for @qrHint. + /// + /// In zh, this message translates to: + /// **'扫码或打开链接观看'** + String get qrHint; + + /// No description provided for @copyLink. + /// + /// In zh, this message translates to: + /// **'复制链接'** + String get copyLink; + + /// No description provided for @copiedToClipboard. + /// + /// In zh, this message translates to: + /// **'已复制'** + String get copiedToClipboard; + + /// No description provided for @photosTask9Placeholder. + /// + /// In zh, this message translates to: + /// **'照片管理为占位版本,完整功能将在任务 9 上线'** + String get photosTask9Placeholder; + + /// No description provided for @albumPhotosEmpty. + /// + /// In zh, this message translates to: + /// **'暂无照片'** + String get albumPhotosEmpty; + + /// No description provided for @visibilityPublished. + /// + /// In zh, this message translates to: + /// **'已发布'** + String get visibilityPublished; + + /// No description provided for @visibilityUnpublished. + /// + /// In zh, this message translates to: + /// **'未发布'** + String get visibilityUnpublished; + + /// No description provided for @visibilityHidden. + /// + /// In zh, this message translates to: + /// **'已隐藏'** + String get visibilityHidden; + + /// No description provided for @visibilityDeleted. + /// + /// In zh, this message translates to: + /// **'已删除'** + String get visibilityDeleted; + + /// No description provided for @procPendingUpload. + /// + /// In zh, this message translates to: + /// **'待上传'** + String get procPendingUpload; + + /// No description provided for @procUploaded. + /// + /// In zh, this message translates to: + /// **'已上传'** + String get procUploaded; + + /// No description provided for @procProcessing. + /// + /// In zh, this message translates to: + /// **'处理中'** + String get procProcessing; + + /// No description provided for @procReady. + /// + /// In zh, this message translates to: + /// **'就绪'** + String get procReady; + + /// No description provided for @procProcessingFailed. + /// + /// In zh, this message translates to: + /// **'处理失败'** + String get procProcessingFailed; } class _AppLocalizationsDelegate diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index b8f1624..902475b 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -120,4 +120,222 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorInternal => 'Service temporarily unavailable. Please retry later'; + + @override + String get albumsEmpty => + 'No albums yet. Tap \"Create album\" to get started'; + + @override + String get loadMoreFailed => 'Failed to load more. Tap to retry'; + + @override + String get albumCreate => 'Create album'; + + @override + String get albumEdit => 'Edit album'; + + @override + String get albumTitle => 'Title'; + + @override + String get albumDescription => 'Description'; + + @override + String get albumVenue => 'Venue'; + + @override + String get albumAddress => 'Address'; + + @override + String get albumDefaultLocale => 'Default locale'; + + @override + String get albumStartAt => 'Start time'; + + @override + String get albumEndAtOptional => 'End time (optional)'; + + @override + String get clearEndAt => 'Clear end time'; + + @override + String get albumTimezone => 'Timezone'; + + @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'; + + @override + String get formDefaultLocaleMissing => 'Default locale must have a title'; + + @override + String get formTitleRequiredWithFields => + 'Title is required when description, venue or address is filled'; + + @override + String get formEndAtNotAfterStartAt => 'End time must be after start time'; + + @override + String get create => 'Create'; + + @override + String get save => 'Save'; + + @override + String get albumStatusActive => 'Active'; + + @override + String get albumStatusOffline => 'Offline'; + + @override + String get tagUpload => 'Upload'; + + @override + String get tagPublic => 'Public'; + + @override + String get tagDownload => 'Download'; + + @override + String get tagOriginal => 'Original'; + + @override + String get notSet => 'Not set'; + + @override + String get actionEdit => 'Edit'; + + @override + String get albumMembers => 'Members'; + + @override + String get albumCategories => 'Categories'; + + @override + String get shareQrTitle => 'Share QR code'; + + @override + String get albumPhotos => 'Photos'; + + @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'; + + @override + String get categoryNameEn => 'English name'; + + @override + String get categorySortOrder => 'Sort order'; + + @override + String get categoryNameRequired => 'At least one language name is required'; + + @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)'; + + @override + String get inviteNoResults => 'No matching users'; + + @override + String get invite => 'Invite'; + + @override + String get memberInvited => 'Invited'; + + @override + String get removeMember => 'Remove member'; + + @override + String get removeMemberConfirm => 'Remove this member?'; + + @override + String get roleOwner => 'Owner'; + + @override + String get roleCollaborator => 'Collaborator'; + + @override + String get selfMarker => ' (you)'; + + @override + String get errorMemberConflict => 'User is already a member'; + + @override + String get qrHint => 'Scan the code or open the link to view'; + + @override + String get copyLink => 'Copy link'; + + @override + String get copiedToClipboard => 'Copied'; + + @override + String get photosTask9Placeholder => + 'Photo management is a placeholder; the full experience ships in Task 9'; + + @override + String get albumPhotosEmpty => 'No photos yet'; + + @override + String get visibilityPublished => 'Published'; + + @override + String get visibilityUnpublished => 'Unpublished'; + + @override + String get visibilityHidden => 'Hidden'; + + @override + String get visibilityDeleted => 'Deleted'; + + @override + String get procPendingUpload => 'Pending upload'; + + @override + String get procUploaded => 'Uploaded'; + + @override + String get procProcessing => 'Processing'; + + @override + String get procReady => 'Ready'; + + @override + String get procProcessingFailed => 'Processing failed'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 3297045..35bd7cb 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -118,4 +118,217 @@ class AppLocalizationsZh extends AppLocalizations { @override String get errorInternal => '服务暂时不可用,请稍后重试'; + + @override + String get albumsEmpty => '暂无相册,点击「新建相册」开始'; + + @override + String get loadMoreFailed => '加载更多失败,点击重试'; + + @override + String get albumCreate => '新建相册'; + + @override + String get albumEdit => '编辑相册'; + + @override + String get albumTitle => '标题'; + + @override + String get albumDescription => '描述'; + + @override + String get albumVenue => '场地'; + + @override + String get albumAddress => '地址'; + + @override + String get albumDefaultLocale => '默认语言'; + + @override + String get albumStartAt => '开始时间'; + + @override + String get albumEndAtOptional => '结束时间(可选)'; + + @override + String get clearEndAt => '清除结束时间'; + + @override + String get albumTimezone => '时区'; + + @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 => '请选择开始时间'; + + @override + String get formDefaultLocaleMissing => '默认语言必须填写标题'; + + @override + String get formTitleRequiredWithFields => '填写了描述/场地/地址时,标题不能为空'; + + @override + String get formEndAtNotAfterStartAt => '结束时间必须晚于开始时间'; + + @override + String get create => '创建'; + + @override + String get save => '保存'; + + @override + String get albumStatusActive => '正常'; + + @override + String get albumStatusOffline => '下线'; + + @override + String get tagUpload => '上传'; + + @override + String get tagPublic => '公开'; + + @override + String get tagDownload => '下载'; + + @override + String get tagOriginal => '原图'; + + @override + String get notSet => '未定'; + + @override + String get actionEdit => '编辑'; + + @override + String get albumMembers => '成员管理'; + + @override + String get albumCategories => '分类管理'; + + @override + String get shareQrTitle => '分享二维码'; + + @override + String get albumPhotos => '照片管理'; + + @override + String get delete => '删除'; + + @override + String get deleteConfirmCategory => '确定删除该分类?该分类下的照片将变为未分类。'; + + @override + String get categoryEmpty => '暂无分类,点击右上角新增'; + + @override + String get categoryAdd => '新增分类'; + + @override + String get categoryEdit => '编辑分类'; + + @override + String get categoryNameZh => '中文名称'; + + @override + String get categoryNameEn => '英文名称'; + + @override + String get categorySortOrder => '排序序号'; + + @override + String get categoryNameRequired => '至少填写一个语言的名称'; + + @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 => '已邀请'; + + @override + String get removeMember => '移除成员'; + + @override + String get removeMemberConfirm => '确定移除该成员?'; + + @override + String get roleOwner => '创建者'; + + @override + String get roleCollaborator => '协作者'; + + @override + String get selfMarker => '(我)'; + + @override + String get errorMemberConflict => '该用户已是相册成员'; + + @override + String get qrHint => '扫码或打开链接观看'; + + @override + String get copyLink => '复制链接'; + + @override + String get copiedToClipboard => '已复制'; + + @override + String get photosTask9Placeholder => '照片管理为占位版本,完整功能将在任务 9 上线'; + + @override + String get albumPhotosEmpty => '暂无照片'; + + @override + String get visibilityPublished => '已发布'; + + @override + String get visibilityUnpublished => '未发布'; + + @override + String get visibilityHidden => '已隐藏'; + + @override + String get visibilityDeleted => '已删除'; + + @override + String get procPendingUpload => '待上传'; + + @override + String get procUploaded => '已上传'; + + @override + String get procProcessing => '处理中'; + + @override + String get procReady => '就绪'; + + @override + String get procProcessingFailed => '处理失败'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index a0a4c58..531d9bf 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -36,5 +36,76 @@ "errorValidation": "输入有误,请检查后重试", "errorNetwork": "网络连接失败,请检查网络后重试", "errorNetworkTimeout": "网络超时,请重试", - "errorInternal": "服务暂时不可用,请稍后重试" + "errorInternal": "服务暂时不可用,请稍后重试", + "albumsEmpty": "暂无相册,点击「新建相册」开始", + "loadMoreFailed": "加载更多失败,点击重试", + "albumCreate": "新建相册", + "albumEdit": "编辑相册", + "albumTitle": "标题", + "albumDescription": "描述", + "albumVenue": "场地", + "albumAddress": "地址", + "albumDefaultLocale": "默认语言", + "albumStartAt": "开始时间", + "albumEndAtOptional": "结束时间(可选)", + "clearEndAt": "清除结束时间", + "albumTimezone": "时区", + "albumTimezoneInvalid": "无效的 IANA 时区名称", + "switchUpload": "允许上传", + "switchPublic": "公开可见", + "switchDownload": "允许下载", + "switchOriginalDownload": "允许下载原图", + "formStartAtRequired": "请选择开始时间", + "formDefaultLocaleMissing": "默认语言必须填写标题", + "formTitleRequiredWithFields": "填写了描述/场地/地址时,标题不能为空", + "formEndAtNotAfterStartAt": "结束时间必须晚于开始时间", + "create": "创建", + "save": "保存", + "albumStatusActive": "正常", + "albumStatusOffline": "下线", + "tagUpload": "上传", + "tagPublic": "公开", + "tagDownload": "下载", + "tagOriginal": "原图", + "notSet": "未定", + "actionEdit": "编辑", + "albumMembers": "成员管理", + "albumCategories": "分类管理", + "shareQrTitle": "分享二维码", + "albumPhotos": "照片管理", + "delete": "删除", + "deleteConfirmCategory": "确定删除该分类?该分类下的照片将变为未分类。", + "categoryEmpty": "暂无分类,点击右上角新增", + "categoryAdd": "新增分类", + "categoryEdit": "编辑分类", + "categoryNameZh": "中文名称", + "categoryNameEn": "英文名称", + "categorySortOrder": "排序序号", + "categoryNameRequired": "至少填写一个语言的名称", + "categorySortOrderInvalid": "排序序号必须是整数", + "albumMembersEmpty": "暂无成员", + "inviteSearchHint": "输入账号前缀搜索邀请(至少 2 个字符)", + "inviteNoResults": "未找到匹配用户", + "invite": "邀请", + "memberInvited": "已邀请", + "removeMember": "移除成员", + "removeMemberConfirm": "确定移除该成员?", + "roleOwner": "创建者", + "roleCollaborator": "协作者", + "selfMarker": "(我)", + "errorMemberConflict": "该用户已是相册成员", + "qrHint": "扫码或打开链接观看", + "copyLink": "复制链接", + "copiedToClipboard": "已复制", + "photosTask9Placeholder": "照片管理为占位版本,完整功能将在任务 9 上线", + "albumPhotosEmpty": "暂无照片", + "visibilityPublished": "已发布", + "visibilityUnpublished": "未发布", + "visibilityHidden": "已隐藏", + "visibilityDeleted": "已删除", + "procPendingUpload": "待上传", + "procUploaded": "已上传", + "procProcessing": "处理中", + "procReady": "就绪", + "procProcessingFailed": "处理失败" } diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart new file mode 100644 index 0000000..01a10df --- /dev/null +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -0,0 +1,740 @@ +/// 相册详情页(P3 计划 Task 4):信息区(标题/时间/时区/令牌/开关标签) +/// + 动作行(编辑、成员管理、分类管理=滚动定位、分享二维码、照片管理 +/// 占位)+ 分类管理区(列表 + 新增/编辑对话框 + 删除确认)。 +/// 编辑与分类变更后刷新本地详情并同步全局相册列表。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/api_client.dart'; +import '../core/app_exception.dart'; +import '../core/error_codes.dart'; +import '../core/error_l10n.dart'; +import '../features/albums/album_format.dart'; +import '../features/albums/models.dart'; +import '../features/albums/providers.dart'; +import '../l10n/app_localizations.dart'; +import 'album_form_page.dart'; +import 'album_members_page.dart'; +import 'album_photos_page.dart'; +import 'share_qr_dialog.dart'; + +/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id})。 +class AlbumDetailPage extends ConsumerStatefulWidget { + const AlbumDetailPage({required this.albumId, super.key}); + + final String albumId; + + @override + ConsumerState createState() => _AlbumDetailPageState(); +} + +class _AlbumDetailPageState extends ConsumerState { + final GlobalKey _categoriesKey = GlobalKey(); + final ScrollController _scrollController = ScrollController(); + + Album? _album; + bool _loading = true; + String? _errorCode; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { + _loading = true; + _errorCode = null; + }); + try { + final album = await ref.read(apiClientProvider).getAlbum( + widget.albumId, + ); + if (mounted) { + setState(() { + _album = album; + _loading = false; + }); + } + } on AppException catch (e) { + if (mounted) { + setState(() { + _errorCode = e.code; + _loading = false; + }); + } + } on Object { + if (mounted) { + setState(() { + _errorCode = ErrorCodes.internalError; + _loading = false; + }); + } + } + } + + /// 编辑成功后:详情重载 + 全局列表同步。 + Future _openEdit() async { + final album = _album; + if (album == null) return; + final changed = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AlbumFormPage(existing: album), + ), + ); + if (changed == true) { + await _load(); + ref.read(albumListProvider.notifier).refresh(); + } + } + + Future _openMembers() async { + final album = _album; + if (album == null) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AlbumMembersPage(album: album), + ), + ); + // 成员变更不影响详情数据,回来无需刷新。 + } + + Future _openPhotos() async { + final album = _album; + if (album == null) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AlbumPhotosPage(album: album), + ), + ); + } + + void _shareQr() { + final album = _album; + if (album == null) return; + showDialog( + context: context, + builder: (_) => ShareQrDialog(publicToken: album.publicToken), + ); + } + + void _scrollToCategories() { + final ctx = _categoriesKey.currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + } + + Future _deleteCategory(Category category) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(l10n.delete), + content: Text(l10n.deleteConfirmCategory), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.delete), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + final messenger = ScaffoldMessenger.of(context); + try { + await ref + .read(apiClientProvider) + .deleteCategory(category.id); + await _load(); + } on AppException catch (e) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.messageForError(e))), + ); + } on Object { + messenger.showSnackBar( + SnackBar(content: Text(l10n.errorGeneric)), + ); + } + } + + Future _openCategoryDialog([Category? existing]) async { + final album = _album; + if (album == null) return; + final changed = await showDialog( + context: context, + builder: (_) => CategoryEditDialog(albumId: album.id, existing: existing), + ); + if (changed == true) await _load(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final album = _album; + final title = album == null ? l10n.albumDetail : _titleOf(album); + return Scaffold( + appBar: AppBar(title: Text(title)), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : album == null + ? _ErrorRetryView( + message: l10n.messageForErrorCode(_errorCode!), + onRetry: _load, + ) + : SingleChildScrollView( + controller: _scrollController, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _InfoCard(album: album), + const SizedBox(height: 16), + _ActionsRow( + onEdit: _openEdit, + onMembers: _openMembers, + onCategories: _scrollToCategories, + onShareQr: _shareQr, + onPhotos: _openPhotos, + ), + const SizedBox(height: 16), + _CategoriesSection( + key: _categoriesKey, + album: album, + onAdd: () => _openCategoryDialog(), + onEdit: (c) => _openCategoryDialog(c), + onDelete: _deleteCategory, + ), + const SizedBox(height: 32), + ], + ), + ), + ), + ); + } + + static String _titleOf(Album album) { + for (final t in album.translations) { + if (t.locale == album.defaultLocale) return t.title; + } + if (album.translations.isNotEmpty) return album.translations.first.title; + return album.id; + } +} + +/// 首屏错误 + 重试。 +class _ErrorRetryView extends StatelessWidget { + const _ErrorRetryView({required this.message, required this.onRetry}); + + final String message; + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off, size: 48), + const SizedBox(height: 12), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(l10n.retry), + ), + ], + ), + ); + } +} + +/// 信息卡:标题 + 状态、默认语言内容(描述/场地/地址)、时区时间区间、 +/// 公开令牌、四开关标签。 +class _InfoCard extends StatelessWidget { + const _InfoCard({required this.album}); + + final Album album; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final translation = album.translations + .where((t) => t.locale == album.defaultLocale) + .firstOrNull; + final start = + formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName); + final end = album.endAt == null + ? l10n.notSet + : formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + translation?.title ?? album.id, + style: theme.textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + _AlbumStatusBadge(status: album.status), + ], + ), + if ((translation?.description ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 8), + Text(translation!.description!), + ], + if ((translation?.venue ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.place_outlined, size: 16), + const SizedBox(width: 4), + Expanded(child: Text(translation!.venue!)), + ], + ), + ], + if ((translation?.address ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.signpost_outlined, size: 16), + const SizedBox(width: 4), + Expanded(child: Text(translation!.address!)), + ], + ), + ], + const SizedBox(height: 12), + Row( + children: [ + const Icon(Icons.schedule, size: 16), + const SizedBox(width: 4), + Expanded(child: Text('$start ~ $end (${album.timezone})')), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.link, size: 16), + const SizedBox(width: 4), + Expanded( + child: Text( + album.publicToken, + style: theme.textTheme.bodySmall?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _switchTag(context, l10n.tagUpload, album.uploadEnabled), + _switchTag(context, l10n.tagPublic, album.publicEnabled), + _switchTag( + context, + l10n.tagDownload, + album.downloadEnabled, + ), + _switchTag( + context, + l10n.tagOriginal, + album.originalDownloadEnabled, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _switchTag(BuildContext context, String label, bool on) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: on + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: on + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } +} + +/// 动作行:编辑 / 成员管理 / 分类管理(滚动定位) / 分享二维码 / 照片管理。 +class _ActionsRow extends StatelessWidget { + const _ActionsRow({ + required this.onEdit, + required this.onMembers, + required this.onCategories, + required this.onShareQr, + required this.onPhotos, + }); + + final VoidCallback onEdit; + final VoidCallback onMembers; + final VoidCallback onCategories; + final VoidCallback onShareQr; + final VoidCallback onPhotos; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonalIcon( + onPressed: onEdit, + icon: const Icon(Icons.edit_outlined), + label: Text(l10n.actionEdit), + ), + FilledButton.tonalIcon( + onPressed: onMembers, + icon: const Icon(Icons.group_outlined), + label: Text(l10n.albumMembers), + ), + FilledButton.tonalIcon( + onPressed: onCategories, + icon: const Icon(Icons.category_outlined), + label: Text(l10n.albumCategories), + ), + FilledButton.tonalIcon( + onPressed: onShareQr, + icon: const Icon(Icons.qr_code_2), + label: Text(l10n.shareQrTitle), + ), + FilledButton.tonalIcon( + onPressed: onPhotos, + icon: const Icon(Icons.photo_outlined), + label: Text(l10n.albumPhotos), + ), + ], + ); + } +} + +/// 分类管理区:列表(点击编辑、尾随删除)+ 新增入口。 +class _CategoriesSection extends StatelessWidget { + const _CategoriesSection({ + super.key, + required this.album, + required this.onAdd, + required this.onEdit, + required this.onDelete, + }); + + final Album album; + final VoidCallback onAdd; + final void Function(Category category) onEdit; + final Future Function(Category category) onDelete; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.albumCategories, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + IconButton( + tooltip: l10n.categoryAdd, + onPressed: onAdd, + icon: const Icon(Icons.add_circle_outline), + ), + ], + ), + if (album.categories.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + l10n.categoryEmpty, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ) + else + Card( + 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(' · '); + } +} + +/// 状态徽标(详情信息卡用)。 +class _AlbumStatusBadge extends StatelessWidget { + const _AlbumStatusBadge({required this.status}); + + final AlbumStatus status; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final active = status == AlbumStatus.active; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: active + ? theme.colorScheme.secondaryContainer + : theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + active ? l10n.albumStatusActive : l10n.albumStatusOffline, + style: theme.textTheme.labelSmall?.copyWith( + color: active + ? theme.colorScheme.onSecondaryContainer + : theme.colorScheme.onErrorContainer, + ), + ), + ); + } +} + +/// 分类新增/编辑对话框:排序序号 + 中文名 + 英文名(至少一个必填)。 +/// +/// 成功 pop(true);校验失败就地提示;API 失败 SnackBar(对话框保持)。 +class CategoryEditDialog extends ConsumerStatefulWidget { + const CategoryEditDialog({ + required this.albumId, + this.existing, + super.key, + }); + + final String albumId; + + /// 编辑目标;null = 新增。 + final Category? existing; + + @override + ConsumerState createState() => _CategoryEditDialogState(); +} + +class _CategoryEditDialogState extends ConsumerState { + late final TextEditingController _sortOrder = + TextEditingController(text: (widget.existing?.sortOrder ?? 0).toString()); + late final TextEditingController _zhName = TextEditingController( + text: widget.existing?.translations + .where((t) => t.locale == 'zh-CN') + .firstOrNull + ?.name ?? + '', + ); + late final TextEditingController _enName = TextEditingController( + text: widget.existing?.translations + .where((t) => t.locale == 'en') + .firstOrNull + ?.name ?? + '', + ); + + String? _error; + bool _submitting = false; + + @override + void dispose() { + _sortOrder.dispose(); + _zhName.dispose(); + _enName.dispose(); + super.dispose(); + } + + Future _submit() async { + final l10n = AppLocalizations.of(context); + final zh = _zhName.text.trim(); + final en = _enName.text.trim(); + if (zh.isEmpty && en.isEmpty) { + setState(() => _error = l10n.categoryNameRequired); + return; + } + final sortOrder = int.tryParse(_sortOrder.text.trim()); + if (sortOrder == null) { + setState(() => _error = l10n.categorySortOrderInvalid); + return; + } + setState(() { + _error = null; + _submitting = true; + }); + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + final translations = [ + if (zh.isNotEmpty) CategoryTranslation(locale: 'zh-CN', name: zh), + if (en.isNotEmpty) CategoryTranslation(locale: 'en', name: en), + ]; + try { + final api = ref.read(apiClientProvider); + if (widget.existing == null) { + await api.createCategory( + widget.albumId, + CategoryCreateRequest( + sortOrder: sortOrder, + translations: translations, + ), + ); + } else { + await api.updateCategory( + widget.existing!.id, + CategoryUpdateRequest( + sortOrder: sortOrder, + translations: translations, + ), + ); + } + navigator.pop(true); + } on AppException catch (e) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.messageForError(e))), + ); + if (mounted) setState(() => _submitting = false); + } on Object { + messenger.showSnackBar( + SnackBar(content: Text(l10n.errorGeneric)), + ); + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return AlertDialog( + title: Text(widget.existing == null ? l10n.categoryAdd : l10n.categoryEdit), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _sortOrder, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: l10n.categorySortOrder, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _zhName, + decoration: InputDecoration( + labelText: l10n.categoryNameZh, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _enName, + decoration: InputDecoration( + labelText: l10n.categoryNameEn, + border: const OutlineInputBorder(), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: _submitting ? null : () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.save), + ), + ], + ); + } +} diff --git a/apps/mobile/lib/pages/album_form_page.dart b/apps/mobile/lib/pages/album_form_page.dart new file mode 100644 index 0000000..66f2f0f --- /dev/null +++ b/apps/mobile/lib/pages/album_form_page.dart @@ -0,0 +1,460 @@ +/// 相册创建/编辑表单页(P3 计划 Task 4)。 +/// +/// zh-CN / en 双语 tab(标题/描述/场地/地址)、默认语言分段选择、 +/// 日期时间选择器(开始必填;结束可选、可显式清除)、时区文本(缺省 +/// Asia/Shanghai)、开关组:创建仅 download/original(契约缺省 +/// upload/public=true 不可创建时覆盖),编辑为四开关独立。 +/// 编辑提交经 [AlbumFormDraft.toUpdatePatch] 最小化 PATCH(仅变更字段); +/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情。 +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/album_format.dart'; +import '../features/albums/album_form.dart'; +import '../features/albums/models.dart'; +import '../l10n/app_localizations.dart'; + +/// 创建([existing] 为 null)/ 编辑相册表单。 +class AlbumFormPage extends ConsumerStatefulWidget { + const AlbumFormPage({this.existing, this.initialDraft, super.key}); + + /// 编辑目标;null = 创建模式。 + final Album? existing; + + /// 初始草稿(测试注入/预填);缺省从 [existing] 构建或全新(创建)。 + final AlbumFormDraft? initialDraft; + + @override + ConsumerState createState() => _AlbumFormPageState(); +} + +class _AlbumFormPageState extends ConsumerState { + static const String _zh = 'zh-CN'; + static const String _en = 'en'; + + late final List _controllers; + + late final TextEditingController _zhTitle; + late final TextEditingController _zhDescription; + late final TextEditingController _zhVenue; + late final TextEditingController _zhAddress; + late final TextEditingController _enTitle; + late final TextEditingController _enDescription; + late final TextEditingController _enVenue; + late final TextEditingController _enAddress; + late final TextEditingController _timezone; + + late String _defaultLocale; + DateTime? _startAt; + DateTime? _endAt; + late bool _uploadEnabled; + late bool _publicEnabled; + late bool _downloadEnabled; + late bool _originalDownloadEnabled; + + int _tabIndex = 0; + List _errors = const []; + bool _submitting = false; + + bool get _isCreate => widget.existing == null; + + @override + void initState() { + super.initState(); + final initial = widget.existing == null + ? (widget.initialDraft ?? + AlbumFormDraft( + zh: const AlbumTranslationDraft(locale: _zh), + en: const AlbumTranslationDraft(locale: _en), + defaultLocale: _zh, + timezone: 'Asia/Shanghai', + )) + : (widget.initialDraft ?? AlbumFormDraft.fromAlbum(widget.existing!)); + _zhTitle = TextEditingController(text: initial.zh.title); + _zhDescription = TextEditingController(text: initial.zh.description); + _zhVenue = TextEditingController(text: initial.zh.venue); + _zhAddress = TextEditingController(text: initial.zh.address); + _enTitle = TextEditingController(text: initial.en.title); + _enDescription = TextEditingController(text: initial.en.description); + _enVenue = TextEditingController(text: initial.en.venue); + _enAddress = TextEditingController(text: initial.en.address); + _timezone = TextEditingController(text: initial.timezone); + _controllers = [ + _zhTitle, + _zhDescription, + _zhVenue, + _zhAddress, + _enTitle, + _enDescription, + _enVenue, + _enAddress, + _timezone, + ]; + _defaultLocale = initial.defaultLocale; + _startAt = initial.startAt; + _endAt = initial.endAt; + _uploadEnabled = initial.uploadEnabled; + _publicEnabled = initial.publicEnabled; + _downloadEnabled = initial.downloadEnabled; + _originalDownloadEnabled = initial.originalDownloadEnabled; + } + + @override + void dispose() { + for (final c in _controllers) { + c.dispose(); + } + super.dispose(); + } + + AlbumFormDraft _currentDraft() => AlbumFormDraft( + zh: AlbumTranslationDraft( + locale: _zh, + title: _zhTitle.text, + description: _zhDescription.text, + venue: _zhVenue.text, + address: _zhAddress.text, + ), + en: AlbumTranslationDraft( + locale: _en, + title: _enTitle.text, + description: _enDescription.text, + venue: _enVenue.text, + address: _enAddress.text, + ), + defaultLocale: _defaultLocale, + timezone: _timezone.text, + startAt: _startAt, + endAt: _endAt, + uploadEnabled: _uploadEnabled, + publicEnabled: _publicEnabled, + downloadEnabled: _downloadEnabled, + originalDownloadEnabled: _originalDownloadEnabled, + ); + + /// 编辑模式:与原值无任何差异时禁用保存(PATCH minProperties 1)。 + bool get _canSubmit { + if (_submitting) return false; + if (_isCreate) return true; + return _currentDraft().toUpdatePatch(widget.existing!) != null; + } + + Future _submit() async { + final draft = _currentDraft(); + final errors = draft.validate(); + if (errors.isNotEmpty) { + setState(() => _errors = errors); + return; + } + setState(() { + _submitting = true; + _errors = const []; + }); + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + try { + if (_isCreate) { + await ref.read(apiClientProvider).createAlbum(draft.toCreateRequest()); + } else { + final patch = draft.toUpdatePatch(widget.existing!); + if (patch == null) { + navigator.pop(false); + return; + } + await ref + .read(apiClientProvider) + .updateAlbum(widget.existing!.id, patch); + } + navigator.pop(true); + } on AppException catch (error) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.messageForError(error))), + ); + } on Object { + messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + /// 日期 + 时间选择器 → 相册时区墙钟 → UTC 瞬间。 + Future _pickDateTime({required bool isStart}) async { + final tzName = _timezone.text.trim(); + final initial = (isStart ? _startAt : _endAt) ?? _startAt ?? DateTime.now(); + final wall = tryToAlbumZone(initial, tzName) ?? initial.toUtc(); + final pickedDate = await showDatePicker( + context: context, + initialDate: wall, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (pickedDate == null || !mounted) return; + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: wall.hour, minute: wall.minute), + ); + if (pickedTime == null || !mounted) return; + final naive = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + final utc = albumWallClockToUtc(naive, tzName); + setState(() { + if (isStart) { + _startAt = utc; + } else { + _endAt = utc; + } + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + // DefaultTabController 仅为 TabBar 提供 controller(内容区非 + // TabBarView,而是按 _tabIndex 手动切换,避免两 tab 表单失焦重建)。 + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Text(_isCreate ? l10n.albumCreate : l10n.albumEdit), + bottom: TabBar( + onTap: (index) => setState(() => _tabIndex = index), + tabs: [ + Tab(text: l10n.localeChinese), + Tab(text: l10n.localeEnglish), + ], + ), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 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 Divider(height: 32), + Text( + l10n.albumDefaultLocale, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 8), + SegmentedButton( + segments: [ + ButtonSegment(value: _zh, label: Text(l10n.localeChinese)), + ButtonSegment(value: _en, label: Text(l10n.localeEnglish)), + ], + selected: {_defaultLocale}, + onSelectionChanged: (selection) => + setState(() => _defaultLocale = selection.first), + ), + const SizedBox(height: 16), + _dateTimeRow(isStart: true), + _dateTimeRow(isStart: false), + const SizedBox(height: 8), + TextFormField( + controller: _timezone, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: l10n.albumTimezone, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + 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: 8), + ..._errors.map( + (e) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + _errorText(l10n, e), + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ), + ], + const SizedBox(height: 16), + FilledButton( + onPressed: _canSubmit ? _submit : null, + child: _submitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(_isCreate ? l10n.create : l10n.save), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + Widget _translationFields({ + required TextEditingController title, + required TextEditingController description, + required TextEditingController venue, + required TextEditingController address, + required bool isDefault, + }) { + final l10n = AppLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: title, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: isDefault ? '${l10n.albumTitle} *' : l10n.albumTitle, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: description, + onChanged: (_) => setState(() {}), + maxLines: 2, + decoration: InputDecoration( + labelText: l10n.albumDescription, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: venue, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: l10n.albumVenue, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: address, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: l10n.albumAddress, + border: const OutlineInputBorder(), + ), + ), + ], + ); + } + + /// 开始(必填)/ 结束(可选、可清除)时间行;展示按当前时区输入换算。 + Widget _dateTimeRow({required bool isStart}) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final value = isStart ? _startAt : _endAt; + final formatted = value == null + ? null + : formatAlbumDateTime(value, _timezone.text, l10n.localeName); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () => _submitting ? null : _pickDateTime(isStart: isStart), + borderRadius: BorderRadius.circular(8), + child: InputDecorator( + decoration: InputDecoration( + labelText: isStart ? l10n.albumStartAt : l10n.albumEndAtOptional, + border: const OutlineInputBorder(), + suffixIcon: value == null + ? const Icon(Icons.edit_calendar_outlined) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.edit_calendar_outlined), + if (!isStart) + IconButton( + tooltip: l10n.clearEndAt, + icon: const Icon(Icons.clear), + onPressed: _submitting + ? null + : () => setState(() => _endAt = null), + ), + ], + ), + ), + child: Text( + formatted ?? (isStart ? l10n.formStartAtRequired : l10n.notSet), + style: formatted == null + ? theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ) + : theme.textTheme.bodyMedium, + ), + ), + ), + ); + } + + String _errorText(AppLocalizations l10n, AlbumFormError error) { + switch (error) { + case AlbumFormError.defaultLocaleMissing: + return l10n.formDefaultLocaleMissing; + case AlbumFormError.titleRequiredWithFields: + return l10n.formTitleRequiredWithFields; + case AlbumFormError.startAtRequired: + return l10n.formStartAtRequired; + case AlbumFormError.endAtNotAfterStartAt: + return l10n.formEndAtNotAfterStartAt; + case AlbumFormError.timezoneInvalid: + return l10n.albumTimezoneInvalid; + } + } +} diff --git a/apps/mobile/lib/pages/album_members_page.dart b/apps/mobile/lib/pages/album_members_page.dart new file mode 100644 index 0000000..05d10da --- /dev/null +++ b/apps/mobile/lib/pages/album_members_page.dart @@ -0,0 +1,453 @@ +/// 成员管理页(P3 计划 Task 4):成员列表(角色标签、owner 徽标、 +/// 本人/创建者不可移除)+ 账号前缀搜索邀请(300ms 防抖、≥2 字符、 +/// lookupUsers 远程搜索)+ 移除确认。MEMBER_CONFLICT 本地化覆盖。 +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/api_client.dart'; +import '../core/app_exception.dart'; +import '../core/error_codes.dart'; +import '../core/error_l10n.dart'; +import '../features/albums/models.dart'; +import '../l10n/app_localizations.dart'; + +/// 成员管理(从详情页携带完整 [album] 进入)。 +class AlbumMembersPage extends ConsumerStatefulWidget { + const AlbumMembersPage({required this.album, super.key}); + + final Album album; + + @override + ConsumerState createState() => _AlbumMembersPageState(); +} + +class _AlbumMembersPageState extends ConsumerState { + static const int _pageSize = 50; + + List _items = const []; + String? _nextCursor; + bool _hasMore = false; + bool _loading = true; + bool _loadingMore = false; + String? _errorCode; + + /// 当前登录账号(本人移除 UI 拦截用);读取失败按未知处理。 + String? _selfAccount; + + final TextEditingController _searchController = TextEditingController(); + Timer? _debounce; + bool _searching = false; + List? _results; + + @override + void initState() { + super.initState(); + _loadSelfAccount(); + _reload(); + } + + @override + void dispose() { + _debounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + Future _loadSelfAccount() async { + try { + final session = await ref.read(sessionStoreProvider).read(); + if (mounted) { + setState(() => _selfAccount = session?.account); + } + } on Object { + // 存储读取失败不阻断页面(移除按钮保护回退服务端 403/409 裁决)。 + } + } + + Future _reload() async { + setState(() { + _loading = true; + _errorCode = null; + }); + try { + final page = await ref + .read(apiClientProvider) + .listMembers(widget.album.id, limit: _pageSize); + if (mounted) { + setState(() { + _items = page.items; + _nextCursor = page.nextCursor; + _hasMore = page.hasMore; + _loading = false; + }); + } + } on AppException catch (e) { + if (mounted) { + setState(() { + _errorCode = e.code; + _loading = false; + }); + } + } on Object { + if (mounted) { + setState(() { + _errorCode = ErrorCodes.internalError; + _loading = false; + }); + } + } + } + + Future _loadMore() async { + if (_loading || _loadingMore || !_hasMore) return; + setState(() => _loadingMore = true); + try { + final page = await ref.read(apiClientProvider).listMembers( + widget.album.id, + cursor: _nextCursor, + limit: _pageSize, + ); + if (mounted) { + setState(() { + _items = [..._items, ...page.items]; + _nextCursor = page.nextCursor; + _hasMore = page.hasMore; + _loadingMore = false; + }); + } + } on AppException catch (e) { + if (mounted) { + setState(() => _loadingMore = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).messageForError(e))), + ); + } + } on Object { + if (mounted) { + setState(() => _loadingMore = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).errorGeneric), + ), + ); + } + } + } + + // ── 邀请搜索(防抖 300ms、≥2 字符) ── + + void _onSearchChanged(String text) { + _debounce?.cancel(); + final query = text.trim(); + if (query.length < 2) { + setState(() { + _searching = false; + _results = null; + }); + return; + } + _debounce = Timer(const Duration(milliseconds: 300), () { + _runSearch(query); + }); + } + + Future _runSearch(String query) async { + setState(() => _searching = true); + try { + final items = await ref + .read(apiClientProvider) + .lookupUsers(query, limit: 10); + // 迟到响应守卫:仅当输入未再变化时应用结果。 + if (!mounted || query != _searchController.text.trim()) return; + setState(() { + _results = items; + _searching = false; + }); + } on AppException catch (e) { + if (!mounted || query != _searchController.text.trim()) return; + setState(() => _searching = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).messageForError(e)), + ), + ); + } on Object { + if (!mounted || query != _searchController.text.trim()) return; + setState(() => _searching = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).errorGeneric)), + ); + } + } + + Future _invite(AdminUserLookupItem user) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + try { + await ref.read(apiClientProvider).addMember( + widget.album.id, + user.id, + ); + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(l10n.memberInvited)), + ); + _searchController.clear(); + setState(() => _results = null); + await _reload(); + } on AppException catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text( + e.code == ErrorCodes.memberConflict + ? l10n.errorMemberConflict + : l10n.messageForError(e), + ), + ), + ); + } on Object { + messenger.showSnackBar( + SnackBar(content: Text(l10n.errorGeneric)), + ); + } + } + + Future _remove(Member member) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(l10n.removeMember), + content: Text(l10n.removeMemberConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.delete), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + final messenger = ScaffoldMessenger.of(context); + try { + await ref + .read(apiClientProvider) + .removeMember(widget.album.id, member.userId); + await _reload(); + } on AppException catch (e) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.messageForError(e))), + ); + } on Object { + messenger.showSnackBar( + SnackBar(content: Text(l10n.errorGeneric)), + ); + } + } + + bool _canRemove(Member member) => + member.role != MemberRole.owner && member.account != _selfAccount; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar(title: Text(l10n.albumMembers)), + body: Column( + 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(''); + }, + )), + ), + ), + ), + if (_results != null) _searchResults(l10n), + const Divider(height: 1, indent: 16, endIndent: 16), + Expanded(child: _memberList(l10n)), + ], + ), + ); + } + + /// 搜索结果面板(有限高度内滚动;无结果显示占位文案)。 + Widget _searchResults(AppLocalizations l10n) { + final results = _results!; + if (results.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + l10n.inviteNoResults, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } + 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), + ), + ), + ], + ), + ); + } + + 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), + ), + ], + ), + ); + } + 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}); + + final MemberRole role; + + @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( + 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, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/pages/album_photos_page.dart b/apps/mobile/lib/pages/album_photos_page.dart new file mode 100644 index 0000000..bd8f89f --- /dev/null +++ b/apps/mobile/lib/pages/album_photos_page.dart @@ -0,0 +1,322 @@ +/// 照片管理占位页(P3 计划 Task 4):基础管理列表(可见性/处理状态 +/// 标签 + 游标加载更多);完整筛选与操作由 Task 9 队列 UI 替换。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/api_client.dart'; +import '../core/app_exception.dart'; +import '../core/error_codes.dart'; +import '../core/error_l10n.dart'; +import '../features/albums/album_format.dart'; +import '../features/albums/models.dart'; +import '../features/photos/models.dart'; +import '../l10n/app_localizations.dart'; + +/// 照片管理占位(从详情页携带 [album] 进入,时间按相册时区展示)。 +class AlbumPhotosPage extends ConsumerStatefulWidget { + const AlbumPhotosPage({required this.album, super.key}); + + final Album album; + + @override + ConsumerState createState() => _AlbumPhotosPageState(); +} + +class _AlbumPhotosPageState extends ConsumerState { + static const int _pageSize = 20; + + List _items = const []; + String? _nextCursor; + bool _hasMore = false; + bool _loading = true; + bool _loadingMore = false; + String? _errorCode; + + @override + void initState() { + super.initState(); + _reload(); + } + + Future _reload() async { + setState(() { + _loading = true; + _errorCode = null; + }); + try { + final page = await ref.read(apiClientProvider).listAlbumPhotos( + widget.album.id, + limit: _pageSize, + ); + if (mounted) { + setState(() { + _items = page.items; + _nextCursor = page.nextCursor; + _hasMore = page.hasMore; + _loading = false; + }); + } + } on AppException catch (e) { + if (mounted) { + setState(() { + _errorCode = e.code; + _loading = false; + }); + } + } on Object { + if (mounted) { + setState(() { + _errorCode = ErrorCodes.internalError; + _loading = false; + }); + } + } + } + + Future _loadMore() async { + if (_loading || _loadingMore || !_hasMore) return; + setState(() => _loadingMore = true); + try { + final page = await ref.read(apiClientProvider).listAlbumPhotos( + widget.album.id, + cursor: _nextCursor, + limit: _pageSize, + ); + if (mounted) { + setState(() { + _items = [..._items, ...page.items]; + _nextCursor = page.nextCursor; + _hasMore = page.hasMore; + _loadingMore = false; + }); + } + } on AppException { + if (mounted) { + setState(() => _loadingMore = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).messageForErrorCode( + ErrorCodes.networkError, + ), + ), + ), + ); + } + } on Object { + if (mounted) { + setState(() => _loadingMore = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).errorGeneric), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: Text(l10n.albumPhotos)), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Task 9 占位横幅:完整筛选/操作届时替换本页。 + Card( + margin: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.construction_outlined, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.photosTask9Placeholder, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _items.isEmpty && _errorCode != null + ? 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), + ), + ], + ), + ) + : ListView.builder( + itemCount: _items.length + + (_items.isEmpty || _hasMore || _loadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (_items.isEmpty) { + return Padding( + padding: const EdgeInsets.all(32), + child: Text( + l10n.albumPhotosEmpty, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall, + ), + ); + } + 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(), + ); + } + return _photoTile(l10n, theme, _items[index]); + }, + ), + ), + ], + ), + ); + } + + Widget _photoTile( + AppLocalizations l10n, + ThemeData theme, + AdminPhoto photo, + ) { + return ListTile( + leading: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.image_outlined), + ), + title: Text( + '${photo.photoId.substring(0, 8)} · ${(photo.bytes / 1024).round()} KB', + style: theme.textTheme.bodyMedium?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + subtitle: Text( + formatAlbumDateTime( + photo.uploadedAt, + widget.album.timezone, + l10n.localeName, + ), + ), + trailing: Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _PhotoTag( + label: _visibilityLabel(l10n, photo.visibility), + highlight: photo.visibility == PhotoVisibility.published, + ), + _PhotoTag( + label: _processingLabel(l10n, photo.processingState), + highlight: photo.processingState == PhotoProcessingState.ready, + ), + ], + ), + ); + } + + static String _visibilityLabel(AppLocalizations l10n, PhotoVisibility v) { + switch (v) { + case PhotoVisibility.published: + return l10n.visibilityPublished; + case PhotoVisibility.unpublished: + return l10n.visibilityUnpublished; + case PhotoVisibility.hidden: + return l10n.visibilityHidden; + case PhotoVisibility.deleted: + return l10n.visibilityDeleted; + } + } + + static String _processingLabel( + AppLocalizations l10n, + PhotoProcessingState s, + ) { + switch (s) { + case PhotoProcessingState.pendingUpload: + return l10n.procPendingUpload; + case PhotoProcessingState.uploaded: + return l10n.procUploaded; + case PhotoProcessingState.processing: + return l10n.procProcessing; + case PhotoProcessingState.ready: + return l10n.procReady; + case PhotoProcessingState.processingFailed: + return l10n.procProcessingFailed; + } + } +} + +/// 照片标签(可见性/处理状态)。 +class _PhotoTag extends StatelessWidget { + const _PhotoTag({required this.label, required this.highlight}); + + final String label; + final bool highlight; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: highlight + ? theme.colorScheme.secondaryContainer + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: highlight + ? theme.colorScheme.onSecondaryContainer + : theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/apps/mobile/lib/pages/albums_tab.dart b/apps/mobile/lib/pages/albums_tab.dart new file mode 100644 index 0000000..80fcc31 --- /dev/null +++ b/apps/mobile/lib/pages/albums_tab.dart @@ -0,0 +1,335 @@ +/// 相册列表页签(P3 计划 Task 4):游标加载更多、下拉刷新、卡片标签行 +/// (状态 + 四开关)、时区感知的时间展示、公开令牌短显、FAB 新建; +/// 加载/空/错误态齐备。详情/编辑由卡片与列表级刷新联动。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/error_l10n.dart'; +import '../features/albums/album_format.dart'; +import '../features/albums/models.dart'; +import '../features/albums/providers.dart'; +import '../l10n/app_localizations.dart'; +import 'album_detail_page.dart'; +import 'album_form_page.dart'; + +/// 首页「相册」页签。 +class AlbumsTab extends ConsumerWidget { + const AlbumsTab({super.key}); + + Future _openCreate(BuildContext context, WidgetRef ref) async { + final changed = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AlbumFormPage()), + ); + if (changed == true) { + ref.read(albumListProvider.notifier).refresh(); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final state = ref.watch(albumListProvider); + return Scaffold( + appBar: AppBar(title: Text(l10n.albums)), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openCreate(context, ref), + icon: const Icon(Icons.add), + label: Text(l10n.albumCreate), + ), + body: AlbumListBody(state: state), + ); + } +} + +/// 列表主体:按状态分发 加载/错误/空/列表(含加载更多页脚)。 +class AlbumListBody extends ConsumerWidget { + const AlbumListBody({required this.state, super.key}); + + final AlbumListState state; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final notifier = ref.read(albumListProvider.notifier); + + if (state.loading) { + return const Center(child: CircularProgressIndicator()); + } + if (state.items.isEmpty) { + if (state.errorCode != null) { + return _ErrorView( + message: l10n.messageForErrorCode(state.errorCode!), + onRetry: notifier.refresh, + ); + } + return _EmptyView(message: l10n.albumsEmpty); + } + + final hasFooter = state.hasMore || state.loadMoreFailed; + return RefreshIndicator( + onRefresh: notifier.refresh, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: state.items.length + (hasFooter ? 1 : 0), + itemBuilder: (context, index) { + if (index >= state.items.length) { + if (state.loadMoreFailed) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: const Icon(Icons.error_outline), + title: Text(l10n.loadMoreFailed), + onTap: notifier.loadMore, + ); + } + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + // 接近底部时预取下一页(loadMore 自带并发/末页守卫)。 + if (index >= state.items.length - 3 && + state.hasMore && + !state.loadingMore) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => notifier.loadMore(), + ); + } + return AlbumCard(album: state.items[index]); + }, + ), + ); + } +} + +/// 首屏错误视图(重试按钮触发整页刷新)。 +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + + final String message; + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off, size: 48), + const SizedBox(height: 12), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(l10n.retry), + ), + ], + ), + ); + } +} + +/// 空列表视图。 +class _EmptyView extends StatelessWidget { + const _EmptyView({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo_library_outlined, size: 48), + const SizedBox(height: 12), + Text(message, textAlign: TextAlign.center), + ], + ), + ); + } +} + +/// 相册卡片:默认语言标题、状态 + 四开关标签行、相册时区的时间区间、 +/// 公开令牌短显。 +class AlbumCard extends StatelessWidget { + const AlbumCard({required this.album, super.key}); + + final AlbumSummary album; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final time = _timeRange(l10n); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AlbumDetailPage(albumId: album.id), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + _title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + _StatusChip(status: album.status), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _SwitchChip(label: l10n.tagUpload, on: album.uploadEnabled), + _SwitchChip(label: l10n.tagPublic, on: album.publicEnabled), + _SwitchChip( + label: l10n.tagDownload, + on: album.downloadEnabled, + ), + _SwitchChip( + label: l10n.tagOriginal, + on: album.originalDownloadEnabled, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.schedule, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + time, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + album.publicToken.length > 10 + ? '${album.publicToken.substring(0, 10)}…' + : album.publicToken, + style: theme.textTheme.bodySmall?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } + + /// 默认语言标题(缺失时回退首个条目;无翻译时显示 ID 兜底)。 + String get _title { + for (final t in album.translations) { + if (t.locale == album.defaultLocale) return t.title; + } + if (album.translations.isNotEmpty) return album.translations.first.title; + return album.id; + } + + /// 相册时区的时间区间(结束未定显示「未定」占位)。 + String _timeRange(AppLocalizations l10n) { + final locale = l10n.localeName; + final start = formatAlbumDateTime(album.startAt, album.timezone, locale); + final end = album.endAt == null + ? l10n.notSet + : formatAlbumDateTime(album.endAt!, album.timezone, locale); + return '$start ~ $end'; + } +} + +/// 状态标签:active 正常 / offline 下线。 +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.status}); + + final AlbumStatus status; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final active = status == AlbumStatus.active; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: active + ? theme.colorScheme.secondaryContainer + : theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + active ? l10n.albumStatusActive : l10n.albumStatusOffline, + style: theme.textTheme.labelSmall?.copyWith( + color: active + ? theme.colorScheme.onSecondaryContainer + : theme.colorScheme.onErrorContainer, + ), + ), + ); + } +} + +/// 开关标签:色调区分开/关。 +class _SwitchChip extends StatelessWidget { + const _SwitchChip({required this.label, required this.on}); + + final String label; + final bool on; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: on + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: on + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/apps/mobile/lib/pages/home_page.dart b/apps/mobile/lib/pages/home_page.dart index 6507bfa..f70048d 100644 --- a/apps/mobile/lib/pages/home_page.dart +++ b/apps/mobile/lib/pages/home_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api_client.dart'; import '../l10n/app_localizations.dart'; +import 'albums_tab.dart'; /// 主壳:底部导航(相册 / 队列 / 设置)。 class HomePage extends StatefulWidget { @@ -53,25 +54,6 @@ class _HomePageState extends State { } } -/// 相册列表(桩)。Task 4:游标分页、创建/编辑、二维码分享。 -class AlbumsTab extends StatelessWidget { - const AlbumsTab({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - return Scaffold( - appBar: AppBar(title: Text(l10n.albums)), - body: Center( - child: Text( - l10n.albums, - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - ); - } -} - /// 上传队列(桩)。Task 6/9:状态机展示、筛选与重试。 class QueueTab extends StatelessWidget { const QueueTab({super.key}); diff --git a/apps/mobile/lib/pages/share_qr_dialog.dart b/apps/mobile/lib/pages/share_qr_dialog.dart new file mode 100644 index 0000000..d28911a --- /dev/null +++ b/apps/mobile/lib/pages/share_qr_dialog.dart @@ -0,0 +1,101 @@ +/// 分享二维码对话框(P3 计划 Task 4)。 +/// +/// viewer 链接构造:`{VIEWER_BASE}/a/{publicToken}`([ApiConfig. +/// viewerAlbumUrl],VIEWER_BASE 经 --dart-define 覆盖,dev=模拟器宿主 +/// 回环 5175)。QrImageView 渲染链接 + 链接文本可长按选中 + 一键复制。 +/// +/// 用 [Dialog] 而非 [AlertDialog]:AlertDialog 内容经 IntrinsicWidth +/// 测量,而 QrImageView 内部使用 LayoutBuilder(不支持 intrinsic 计算) +/// 会抛布局断言,普通 Dialog 无此路径。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../core/config.dart'; +import '../l10n/app_localizations.dart'; + +/// 观众分享二维码对话框([publicToken] 来自相册详情)。 +class ShareQrDialog extends StatelessWidget { + const ShareQrDialog({required this.publicToken, super.key}); + + final String publicToken; + + Future _copy(BuildContext context, String url) async { + await Clipboard.setData(ClipboardData(text: url)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).copiedToClipboard), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final url = ApiConfig.viewerAlbumUrl(publicToken); + return Dialog( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.shareQrTitle, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Center( + // 白底保证暗色主题下的扫码对比度。 + child: Container( + color: Colors.white, + padding: const EdgeInsets.all(8), + child: QrImageView( + data: url, + version: QrVersions.auto, + size: 216, + backgroundColor: Colors.white, + ), + ), + ), + const SizedBox(height: 12), + SelectableText( + url, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 8), + Text( + l10n.qrHint, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () => _copy(context, url), + icon: const Icon(Icons.copy_outlined), + label: Text(l10n.copyLink), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.ok), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 0d55a94..971b155 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -930,6 +930,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.7.11" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 559b348..cca2947 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: path: ^1.9.1 # l10n(与 flutter_localizations 来自 SDK 的约束保持一致) intl: 0.20.2 + timezone: ^0.11.1 dev_dependencies: flutter_test: diff --git a/apps/mobile/test/features/album_form_test.dart b/apps/mobile/test/features/album_form_test.dart new file mode 100644 index 0000000..703cc6d --- /dev/null +++ b/apps/mobile/test/features/album_form_test.dart @@ -0,0 +1,240 @@ +/// 相册表单纯逻辑测试(P3 计划 Task 4)。 +/// +/// 覆盖校验矩阵(默认语言覆盖/其他字段漏填标题/时间序/时区)与载荷 +/// 构建:创建体形状、PATCH 最小化(无变更→null、单开关独立、endAt +/// 显式清除序列化为 null、translations 全量替换且不带其他键)。 +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/features/albums/album_form.dart'; +import 'package:photo_live/features/albums/models.dart'; + +import '../helpers/fakes.dart'; + +/// 与 albumJson 缺省值对齐的草稿(「原样」编辑基线)。 +AlbumFormDraft draftOf({ + String zhTitle = '开幕晚宴', + String zhVenue = '大宴会厅', + String enTitle = 'Opening Gala', + String defaultLocale = 'zh-CN', + String timezone = 'Asia/Shanghai', + DateTime? startAt, + DateTime? endAt, + bool uploadEnabled = true, + bool publicEnabled = true, + bool downloadEnabled = true, + bool originalDownloadEnabled = false, +}) => + AlbumFormDraft( + zh: AlbumTranslationDraft( + locale: 'zh-CN', + title: zhTitle, + venue: zhVenue, + ), + en: AlbumTranslationDraft(locale: 'en', title: enTitle), + defaultLocale: defaultLocale, + timezone: timezone, + startAt: startAt ?? DateTime.utc(2026, 9, 18, 6, 30), + endAt: endAt, + uploadEnabled: uploadEnabled, + publicEnabled: publicEnabled, + downloadEnabled: downloadEnabled, + originalDownloadEnabled: originalDownloadEnabled, + ); + +void main() { + group('validate(客户端预检矩阵)', () { + test('默认语言标题为空 → defaultLocaleMissing;未选开始 → startAtRequired', () { + // 直接构造(不经 draftOf:其 startAt 缺省会吞掉显式 null)。 + final errors = AlbumFormDraft( + zh: const AlbumTranslationDraft( + locale: 'zh-CN', + title: ' ', + venue: '大宴会厅', + ), + en: const AlbumTranslationDraft(locale: 'en', title: 'Opening Gala'), + defaultLocale: 'zh-CN', + timezone: 'Asia/Shanghai', + startAt: null, + ).validate(); + expect(errors, containsAll([ + AlbumFormError.defaultLocaleMissing, + AlbumFormError.startAtRequired, + ])); + }); + + test('非默认语言填了其他字段但没填标题 → titleRequiredWithFields', () { + final errors = draftOf(enTitle: '') + .validate() + .where((e) => e == AlbumFormError.titleRequiredWithFields) + .toList(); + // en 无标题且无其他字段 → 条目整体跳过,不算错误。 + expect(errors, isEmpty); + + final errors2 = AlbumFormDraft( + zh: const AlbumTranslationDraft(locale: 'zh-CN', title: '标题'), + en: const AlbumTranslationDraft(locale: 'en', venue: 'Main Hall'), + defaultLocale: 'zh-CN', + timezone: 'Asia/Shanghai', + startAt: DateTime.utc(2026, 9, 18, 6, 30), + ).validate(); + expect(errors2, contains(AlbumFormError.titleRequiredWithFields)); + expect(errors2, isNot(contains(AlbumFormError.defaultLocaleMissing))); + }); + + test('endAt 必须严格晚于 startAt(相等/早于报错,null 放行)', () { + final startAt = DateTime.utc(2026, 9, 18, 6, 30); + expect( + draftOf(startAt: startAt, endAt: startAt).validate(), + contains(AlbumFormError.endAtNotAfterStartAt), + ); + expect( + draftOf( + startAt: startAt, + endAt: startAt.subtract(const Duration(minutes: 1)), + ).validate(), + contains(AlbumFormError.endAtNotAfterStartAt), + ); + expect( + draftOf(startAt: startAt, endAt: null).validate(), + isNot(contains(AlbumFormError.endAtNotAfterStartAt)), + ); + }); + + test('时区校验:空白/非法 IANA 名报错,Asia/Shanghai 通过', () { + expect( + draftOf(timezone: '').validate(), + contains(AlbumFormError.timezoneInvalid), + ); + expect( + draftOf(timezone: 'Mars/Olympus').validate(), + contains(AlbumFormError.timezoneInvalid), + ); + expect( + draftOf(timezone: 'Asia/Shanghai').validate(), + isNot(contains(AlbumFormError.timezoneInvalid)), + ); + }); + }); + + group('toCreateRequest', () { + test('载荷形状:trim、空字段归一 null、不含 upload/public', () { + final req = draftOf( + zhTitle: ' 开幕晚宴 ', + zhVenue: ' ', + enTitle: 'Opening Gala', + downloadEnabled: false, + originalDownloadEnabled: true, + ).toCreateRequest(); + final json = req.toJson(); + expect(json['translations'], >[ + { + 'locale': 'zh-CN', + 'title': '开幕晚宴', + 'description': null, + 'venue': null, + 'address': null, + }, + { + 'locale': 'en', + 'title': 'Opening Gala', + 'description': null, + 'venue': null, + 'address': null, + }, + ]); + expect(json['defaultLocale'], 'zh-CN'); + expect(json['startAt'], '2026-09-18T06:30:00.000Z'); + expect(json['endAt'], isNull); + expect(json['timezone'], 'Asia/Shanghai'); + expect(json['downloadEnabled'], false); + expect(json['originalDownloadEnabled'], true); + // 契约:创建请求体不含 uploadEnabled/publicEnabled(服务端缺省 true)。 + expect(json.containsKey('uploadEnabled'), isFalse); + expect(json.containsKey('publicEnabled'), isFalse); + }); + }); + + group('toUpdatePatch(PATCH 最小化)', () { + test('与原值完全一致 → null(不提交)', () { + final original = Album.fromJson(albumJson()); + expect(draftOf().toUpdatePatch(original), isNull); + }); + + test('仅拨动 originalDownloadEnabled → 单键载荷(开关独立性)', () { + final original = Album.fromJson(albumJson()); + final patch = + draftOf(originalDownloadEnabled: true).toUpdatePatch(original); + expect(patch, isNotNull); + expect(patch!.toJson(), { + 'originalDownloadEnabled': true, + }); + }); + + test('endAt 显式清除 → 序列化为 "endAt": null', () { + final original = Album.fromJson( + albumJson(endAt: '2026-09-19T10:00:00Z'), + ); + // 用户清除 endAt 后:startAt/endAt 为原开始时间与 null。 + final patch = AlbumFormDraft( + zh: AlbumTranslationDraft( + locale: 'zh-CN', + title: '开幕晚宴', + venue: '大宴会厅', + ), + en: const AlbumTranslationDraft(locale: 'en', title: 'Opening Gala'), + defaultLocale: 'zh-CN', + timezone: 'Asia/Shanghai', + startAt: original.startAt, + endAt: null, + ).toUpdatePatch(original); + expect(patch, isNotNull); + expect(patch!.toJson(), {'endAt': null}); + }); + + test('仅修改标题 → translations 全量替换且不含其他键', () { + final original = Album.fromJson(albumJson()); + final patch = + draftOf(zhTitle: '闭幕演出').toUpdatePatch(original); + expect(patch, isNotNull); + final json = patch!.toJson(); + expect(json.keys, ['translations']); + expect(json['translations'], >[ + { + 'locale': 'zh-CN', + 'title': '闭幕演出', + 'description': null, + 'venue': '大宴会厅', + 'address': null, + }, + { + 'locale': 'en', + 'title': 'Opening Gala', + 'description': null, + 'venue': null, + 'address': null, + }, + ]); + }); + + test('切换 defaultLocale(内容未变)→ 仅 defaultLocale 单键', () { + final original = Album.fromJson(albumJson()); + final patch = draftOf(defaultLocale: 'en').toUpdatePatch(original); + expect(patch, isNotNull); + final json = patch!.toJson(); + // 最小化:translations 未变则不重发(服务端按存量校验 en 覆盖, + // 客户端 validate 已先行拦截 en 标题缺失的切换)。 + expect(json, {'defaultLocale': 'en'}); + }); + + test('修改开始时间 → 仅 startAt 键', () { + final original = Album.fromJson(albumJson()); + final patch = draftOf( + startAt: DateTime.utc(2026, 9, 18, 8, 0), + ).toUpdatePatch(original); + expect(patch!.toJson(), { + 'startAt': '2026-09-18T08:00:00.000Z', + }); + }); + }); +} diff --git a/apps/mobile/test/features/album_format_test.dart b/apps/mobile/test/features/album_format_test.dart new file mode 100644 index 0000000..f8e5b9b --- /dev/null +++ b/apps/mobile/test/features/album_format_test.dart @@ -0,0 +1,50 @@ +/// 相册时区格式化纯逻辑测试(P3 计划 Task 4)。 +/// +/// 覆盖:UTC 瞬间 → Asia/Shanghai 墙钟展示、无效时区回退 UTC、 +/// 墙钟 → UTC 提交往返一致、IANA 时区名校验。 +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:photo_live/features/albums/album_format.dart'; + +void main() { + setUpAll(initializeDateFormatting); + + test('UTC 瞬间按 Asia/Shanghai 展示(intl 格式化)', () { + final utc = DateTime.utc(2026, 9, 18, 6, 30); + expect(formatAlbumDateTime(utc, 'Asia/Shanghai', 'en'), '2026/9/18 14:30'); + }); + + test('非 Asia/Shanghai 时区亦按各自墙钟展示(America/New_York)', () { + final utc = DateTime.utc(2026, 9, 18, 6, 30); + // 9 月为纽约夏令时 UTC-4 → 02:30。 + expect( + formatAlbumDateTime(utc, 'America/New_York', 'en'), + '2026/9/18 02:30', + ); + }); + + test('无效时区回退 UTC 展示', () { + final utc = DateTime.utc(2026, 9, 18, 6, 30); + expect(formatAlbumDateTime(utc, 'Mars/Olympus', 'en'), '2026/9/18 06:30'); + }); + + test('墙钟 → UTC 往返一致(表单提交与回显)', () { + final naive = DateTime(2026, 9, 18, 14, 30); + final utc = albumWallClockToUtc(naive, 'Asia/Shanghai'); + expect(utc.toUtc(), DateTime.utc(2026, 9, 18, 6, 30)); + final zoned = tryToAlbumZone(utc, 'Asia/Shanghai'); + expect(zoned, isNotNull); + expect(zoned!.hour, 14); + expect(zoned.minute, 30); + }); + + test('isValidTimezone:空/非法拒绝,合法通过', () { + expect(isValidTimezone(''), isFalse); + expect(isValidTimezone(' '), isFalse); + expect(isValidTimezone('Not/AZone'), isFalse); + expect(isValidTimezone('Asia/Shanghai'), isTrue); + expect(isValidTimezone('UTC'), isTrue); + }); +} diff --git a/apps/mobile/test/helpers/fakes.dart b/apps/mobile/test/helpers/fakes.dart index 6e1051d..16b2c62 100644 --- a/apps/mobile/test/helpers/fakes.dart +++ b/apps/mobile/test/helpers/fakes.dart @@ -30,12 +30,16 @@ class RecordedRequest { const RecordedRequest({ required this.method, required this.path, + required this.uri, required this.headers, required this.body, }); final String method; final String path; + + /// 完整 URI(含 query,游标/过滤参数断言用)。 + final Uri uri; final Map headers; final Object? body; } @@ -61,6 +65,7 @@ class FakeAdapter implements HttpClientAdapter { RecordedRequest( method: options.method, path: options.path, + uri: options.uri, headers: Map.from(options.headers), body: options.data, ), @@ -98,3 +103,144 @@ Map errorBody(String code) => { 'message': 'stub message', 'requestId': 'req-stub', }; + +// ── 相册域 fixtures(P3 计划 Task 4;形状对齐 openapi.yaml schemas)── + +/// 契约 `Album` 响应体(GET/POST /albums、PATCH /albums/{id})。 +Map albumJson({ + String id = 'alb-1', + String publicToken = 'sharetoken000000000001', + String ownerId = 'u-owner', + String status = 'active', + String defaultLocale = 'zh-CN', + String timezone = 'Asia/Shanghai', + String startAt = '2026-09-18T06:30:00Z', // 上海墙钟 14:30 + String? endAt, + bool uploadEnabled = true, + bool publicEnabled = true, + bool downloadEnabled = true, + bool originalDownloadEnabled = false, + List>? translations, + List> categories = const >[], +}) => + { + 'id': id, + 'publicToken': publicToken, + 'ownerId': ownerId, + 'status': status, + 'defaultLocale': defaultLocale, + 'timezone': timezone, + 'startAt': startAt, + 'endAt': endAt, + 'uploadEnabled': uploadEnabled, + 'publicEnabled': publicEnabled, + 'downloadEnabled': downloadEnabled, + 'originalDownloadEnabled': originalDownloadEnabled, + 'watermarkVersion': 0, + 'translations': translations ?? + >[ + { + 'locale': 'zh-CN', + 'title': '开幕晚宴', + 'description': null, + 'venue': '大宴会厅', + 'address': null, + }, + { + 'locale': 'en', + 'title': 'Opening Gala', + 'description': null, + 'venue': null, + 'address': null, + }, + ], + 'categories': categories, + 'createdAt': '2026-09-01T00:00:00Z', + 'updatedAt': '2026-09-01T00:00:00Z', + 'statsSyncedAt': null, + }; + +/// 契约 `Category` 响应体。 +Map categoryJson({ + String id = 'cat-1', + String albumId = 'alb-1', + int sortOrder = 0, + List>? translations, +}) => + { + 'id': id, + 'albumId': albumId, + 'sortOrder': sortOrder, + 'translations': translations ?? + >[ + {'locale': 'zh-CN', 'name': '仪式'}, + {'locale': 'en', 'name': 'Ceremony'}, + ], + }; + +/// 契约 `Member` 响应体。 +Map memberJson({ + String userId = 'u-2', + String account = 'staff02', + String displayName = 'Staff Two', + String role = 'collaborator', +}) => + { + 'userId': userId, + 'account': account, + 'displayName': displayName, + 'role': role, + 'status': 'active', + }; + +/// 契约 `AdminUserLookupItem` 响应体。 +Map lookupUserJson({ + String id = 'u-9', + String account = 'staff09', + String displayName = 'Staff Nine', +}) => + { + 'id': id, + 'account': account, + 'displayName': displayName, + }; + +/// 契约 `AdminPhoto` 响应体。 +Map adminPhotoJson({ + String photoId = 'ph-00000001', + String albumId = 'alb-1', + String visibility = 'published', + String processingState = 'ready', + int bytes = 123456, +}) => + { + 'photoId': photoId, + 'albumId': albumId, + 'categoryId': null, + 'uploaderId': 'u-2', + 'visibility': visibility, + 'processingState': processingState, + 'bytes': bytes, + 'width': 4000, + 'height': 3000, + 'shotAt': null, + 'uploadedAt': '2026-09-18T07:00:00Z', + 'publishedAt': '2026-09-18T07:05:00Z', + 'likeCount': 0, + 'uploader': { + 'userId': 'u-2', + 'account': 'staff02', + 'displayName': 'Staff Two', + }, + }; + +/// 契约游标分页响应体(hasMore 与 nextCursor 成对)。 +Map cursorPageBody( + List> items, { + String? nextCursor, +}) => + { + 'items': items, + 'nextCursor': nextCursor, + 'hasMore': nextCursor != null, + }; diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart new file mode 100644 index 0000000..cb0ee4b --- /dev/null +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -0,0 +1,269 @@ +/// 相册详情页 widget 测试(P3 计划 Task 4)。 +/// +/// 覆盖:详情信息区(标题/状态/时间时区/令牌/开关标签)与动作行; +/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 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/l10n/app_localizations.dart'; +import 'package:photo_live/pages/album_detail_page.dart'; + +import '../helpers/fakes.dart'; + +void main() { + late FakeSessionStore store; + late List> categories; + + setUp(() { + store = FakeSessionStore(); + categories = >[ + categoryJson(id: 'cat-1', sortOrder: 0), + ]; + }); + + /// 路由:GET 详情(反映可变分类表)/ 分类 CRUD;其余路径直接抛错。 + FakeAdapter detailAdapter({ + (int, Object?)? Function(String method, String path)? onCategoryMutation, + }) => + 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 onCategoryMutation?.call('POST', o.path) ?? (201, categoryJson(id: 'cat-new')); + } + if (o.path.startsWith('/v1/categories/') && o.method == 'PATCH') { + return onCategoryMutation?.call('PATCH', o.path) ?? + (200, categoryJson(id: 'cat-1')); + } + if (o.path.startsWith('/v1/categories/') && o.method == 'DELETE') { + return onCategoryMutation?.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 pumpDetail(WidgetTester tester, ApiClient client) async { + await tester.pumpWidget( + ProviderScope( + overrides: [apiClientProvider.overrideWith((ref) => client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AlbumDetailPage(albumId: 'alb-1'), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('加载详情 → 信息区 + 动作行 + 分类列表', (tester) async { + final adapter = detailAdapter(); + await pumpDetail(tester, clientOf(adapter)); + + // 信息区(标题同时出现在 AppBar 与信息卡)。 + expect(find.text('开幕晚宴'), findsNWidgets(2)); + expect(find.text('Active'), findsOneWidget); + expect( + find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai)'), + findsOneWidget, + ); + expect(find.text('sharetoken000000000001'), findsOneWidget); + expect(find.text('大宴会厅'), findsOneWidget); + expect(find.text('Upload'), findsOneWidget); + expect(find.text('Original'), findsOneWidget); + // 动作行(5 项)。 + expect(find.text('Edit'), findsOneWidget); + expect(find.text('Members'), findsOneWidget); + // 「Categories」同时出现在动作按钮与分区标题。 + expect(find.text('Categories'), findsNWidgets(2)); + expect(find.text('Share QR code'), findsOneWidget); + expect(find.text('Photos'), findsOneWidget); + // 分类条目(zh · en 名称连接)。 + expect(find.text('仪式 · Ceremony'), findsOneWidget); + }); + + testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async { + var posted = false; + final adapter = detailAdapter( + onCategoryMutation: (method, path) { + if (method == 'POST') { + posted = true; + categories = >[ + ...categories, + categoryJson( + id: 'cat-new', + sortOrder: 5, + translations: >[ + {'locale': 'zh-CN', 'name': '签到'}, + {'locale': 'en', 'name': 'Check-in'}, + ], + ), + ]; + return (201, categoryJson(id: 'cat-new', sortOrder: 5)); + } + return null; + }, + ); + await pumpDetail(tester, clientOf(adapter)); + + await tester.tap(find.byIcon(Icons.add_circle_outline)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Chinese name'), + '签到', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'English name'), + 'Check-in', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Sort order'), + '5', + ); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(posted, isTrue); + // 成功后详情重载会追加 GET,故按方法+路径取 POST。 + final request = adapter.requests.singleWhere( + (r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/categories', + ); + expect(request.path, '/v1/albums/alb-1/categories'); + expect(request.body, { + 'sortOrder': 5, + 'translations': >[ + {'locale': 'zh-CN', 'name': '签到'}, + {'locale': 'en', 'name': 'Check-in'}, + ], + }); + // 详情重载后列表含新分类。 + expect(find.text('签到 · Check-in'), findsOneWidget); + }); + + testWidgets('新增分类:名称全空 → 客户端拦截,不发请求', (tester) async { + final adapter = detailAdapter(); + await pumpDetail(tester, clientOf(adapter)); + + final requestsBefore = adapter.requests.length; + await tester.tap(find.byIcon(Icons.add_circle_outline)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pump(); + + expect( + find.text('At least one language name is required'), + findsOneWidget, + ); + expect(adapter.requests, hasLength(requestsBefore)); + }); + + testWidgets('编辑分类:预填并改名 → PATCH 全量 translations + sortOrder', + (tester) async { + var patched = false; + final adapter = detailAdapter( + onCategoryMutation: (method, path) { + if (method == 'PATCH') { + patched = true; + categories = >[ + categoryJson( + id: 'cat-1', + sortOrder: 2, + translations: >[ + {'locale': 'zh-CN', 'name': '签到墙'}, + {'locale': 'en', 'name': 'Ceremony'}, + ], + ), + ]; + return (200, categoryJson(id: 'cat-1')); + } + return null; + }, + ); + await pumpDetail(tester, clientOf(adapter)); + + await tester.tap(find.text('仪式 · Ceremony')); + await tester.pumpAndSettle(); + + // 预填检查。 + final zhField = tester.widget( + find.widgetWithText(TextFormField, 'Chinese name'), + ); + expect(zhField.controller!.text, '仪式'); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Chinese name'), + '签到墙', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Sort order'), + '2', + ); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(patched, isTrue); + // 成功后详情重载会追加 GET,故按方法+路径取 PATCH。 + final request = adapter.requests.singleWhere( + (r) => r.method == 'PATCH' && r.path == '/v1/categories/cat-1', + ); + expect(request.body, { + 'sortOrder': 2, + 'translations': >[ + {'locale': 'zh-CN', 'name': '签到墙'}, + {'locale': 'en', 'name': 'Ceremony'}, + ], + }); + expect(find.text('签到墙 · Ceremony'), findsOneWidget); + }); + + testWidgets('删除分类:确认对话框 → DELETE → 列表移除', (tester) async { + final adapter = detailAdapter( + onCategoryMutation: (method, path) { + if (method == 'DELETE') { + categories = >[]; + return (204, null); + } + return null; + }, + ); + await pumpDetail(tester, clientOf(adapter)); + + await tester.tap(find.byIcon(Icons.delete_outline)); + 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 tester.tap(find.byIcon(Icons.delete_outline)); + 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('仪式 · Ceremony'), findsNothing); + expect(find.text('No categories yet. Tap + to add one'), findsOneWidget); + }); +} diff --git a/apps/mobile/test/pages/album_form_page_test.dart b/apps/mobile/test/pages/album_form_page_test.dart new file mode 100644 index 0000000..f17be1b --- /dev/null +++ b/apps/mobile/test/pages/album_form_page_test.dart @@ -0,0 +1,321 @@ +/// 相册创建/编辑表单 widget 测试(P3 计划 Task 4)。 +/// +/// 覆盖:校验矩阵(默认语言标题缺失/未选开始时间/endAt 早于 startAt)、 +/// 创建成功 POST 载荷形状、编辑预加载(字段与四开关初值、创建仅两开关)、 +/// PATCH 最小化(仅拨动的开关出现在请求体)、endAt 显式清除、 +/// 标题修改仅发全量 translations。 +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/album_form.dart'; +import 'package:photo_live/features/albums/models.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/album_form_page.dart'; + +import '../helpers/fakes.dart'; + +void main() { + late FakeSessionStore store; + + setUp(() { + store = FakeSessionStore(); + }); + + ApiClient clientOf(FakeAdapter adapter) => ApiClient( + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + Future pumpForm( + WidgetTester tester, + ApiClient client, { + Album? existing, + AlbumFormDraft? initialDraft, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [apiClientProvider.overrideWith((ref) => client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AlbumFormPage(existing: existing, initialDraft: initialDraft), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// 创建模式合法草稿(测试注入开始/结束时间,避开平台日期选择器)。 + AlbumFormDraft createDraft({ + String zhTitle = '开幕晚宴', + String enTitle = 'Opening Gala', + String defaultLocale = 'zh-CN', + DateTime? startAt, + DateTime? endAt, + bool downloadEnabled = true, + bool originalDownloadEnabled = false, + }) => + AlbumFormDraft( + zh: AlbumTranslationDraft(locale: 'zh-CN', title: zhTitle), + en: AlbumTranslationDraft(locale: 'en', title: enTitle), + defaultLocale: defaultLocale, + timezone: 'Asia/Shanghai', + startAt: startAt ?? DateTime.utc(2026, 9, 18, 6, 30), + endAt: endAt, + downloadEnabled: downloadEnabled, + originalDownloadEnabled: originalDownloadEnabled, + ); + + /// 表单长于测试视口(800x600):按钮常在折叠线下,tap 前先滚动可见。 + Future tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + } + + testWidgets('创建:空表单提交 → 汇总错误(默认语言标题/开始时间),不发请求', + (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 tester.pump(); + + expect(find.text('Default locale must have a title'), findsOneWidget); + // 开始时间行占位与汇总错误复用同一文案 → 2 处。 + expect(find.text('Pick a start time'), findsNWidgets(2)); + expect(adapter.requests, isEmpty); + }); + + testWidgets('创建:defaultLocale=en 而 en 标题空 → 默认语言覆盖错误', + (tester) async { + final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); + await pumpForm( + tester, + clientOf(adapter), + initialDraft: createDraft(defaultLocale: 'en', enTitle: ''), + ); + + await tapVisible(tester, find.text('Create')); + await tester.pump(); + + expect(find.text('Default locale must have a title'), findsOneWidget); + expect(adapter.requests, isEmpty); + }); + + testWidgets('创建:endAt 早于 startAt → 时间序错误', (tester) async { + final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); + await pumpForm( + tester, + clientOf(adapter), + initialDraft: createDraft( + endAt: DateTime.utc(2026, 9, 18, 5, 0), + ), + ); + + await tapVisible(tester, find.text('Create')); + await tester.pump(); + + expect(find.text('End time must be after start time'), findsOneWidget); + expect(adapter.requests, isEmpty); + }); + + testWidgets('创建:合法提交 → POST 载荷形状(双语 translations/UTC 时间/开关)', + (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? (201, albumJson()) + : throw StateError('unexpected ${o.path}'), + ); + await pumpForm( + tester, + clientOf(adapter), + initialDraft: createDraft( + endAt: DateTime.utc(2026, 9, 19, 10, 0), + downloadEnabled: false, + originalDownloadEnabled: true, + ), + ); + + await tapVisible(tester, find.text('Create')); + await tester.pumpAndSettle(); + + final request = adapter.requests.single; + expect(request.method, 'POST'); + expect(request.path, '/v1/albums'); + expect(request.body, { + 'translations': >[ + { + 'locale': 'zh-CN', + 'title': '开幕晚宴', + 'description': null, + 'venue': null, + 'address': null, + }, + { + 'locale': 'en', + 'title': 'Opening Gala', + 'description': null, + 'venue': null, + 'address': null, + }, + ], + 'defaultLocale': 'zh-CN', + 'startAt': '2026-09-18T06:30:00.000Z', + 'endAt': '2026-09-19T10:00:00.000Z', + 'timezone': 'Asia/Shanghai', + 'downloadEnabled': false, + 'originalDownloadEnabled': true, + }); + }); + + testWidgets('编辑:预加载既有值(字段、四开关独立初值、无变更时保存禁用)', + (tester) async { + final adapter = FakeAdapter( + (o) => throw StateError('编辑预加载不应发起请求:${o.path}'), + ); + final existing = Album.fromJson( + albumJson( + publicEnabled: false, + originalDownloadEnabled: true, + endAt: '2026-09-19T10:00:00Z', + ), + ); + await pumpForm(tester, clientOf(adapter), existing: existing); + + // 编辑模式四开关。 + expect(find.byType(SwitchListTile), findsNWidgets(4)); + // 字段预填(当前 zh tab;title 为第一个文本字段)。 + final titleField = tester.widget( + find.byType(TextFormField).first, + ); + expect(titleField.controller!.text, '开幕晚宴'); + // 开关初值与原相册一致。 + expect( + tester + .widget( + find.widgetWithText(SwitchListTile, 'Allow uploads'), + ) + .value, + true, + ); + expect( + tester + .widget( + find.widgetWithText(SwitchListTile, 'Publicly visible'), + ) + .value, + false, + ); + expect( + tester + .widget( + find.widgetWithText(SwitchListTile, 'Allow original downloads'), + ) + .value, + true, + ); + // 时间回显按相册时区。 + expect(find.text('2026/9/18 14:30'), findsOneWidget); + expect(find.text('2026/9/19 18:00'), findsOneWidget); + // 无任何变更 → 保存禁用(PATCH minProperties 1)。 + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + }); + + testWidgets('编辑:仅拨动 originalDownloadEnabled → PATCH 单键载荷', + (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums/alb-1' + ? (200, albumJson(originalDownloadEnabled: true)) + : throw StateError('unexpected ${o.path}'), + ); + final existing = Album.fromJson(albumJson()); + await pumpForm(tester, clientOf(adapter), existing: existing); + + await tapVisible( + tester, + find.widgetWithText(SwitchListTile, 'Allow original downloads'), + ); + await tester.pump(); + + await tapVisible(tester, find.text('Save')); + await tester.pumpAndSettle(); + + final request = adapter.requests.single; + expect(request.method, 'PATCH'); + expect(request.path, '/v1/albums/alb-1'); + expect(request.body, { + 'originalDownloadEnabled': true, + }); + }); + + testWidgets('编辑:清除 endAt → PATCH 序列化 "endAt": null', (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums/alb-1' + ? (200, albumJson(endAt: null)) + : throw StateError('unexpected ${o.path}'), + ); + final existing = Album.fromJson( + albumJson(endAt: '2026-09-19T10:00:00Z'), + ); + await pumpForm(tester, clientOf(adapter), existing: existing); + + await tapVisible(tester, find.byTooltip('Clear end time')); + await tester.pump(); + + await tapVisible(tester, find.text('Save')); + await tester.pumpAndSettle(); + + expect(adapter.requests.single.body, {'endAt': null}); + }); + + testWidgets('编辑:修改 zh 标题 → 仅发全量 translations(不含开关/时间)', + (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums/alb-1' + ? (200, albumJson()) + : throw StateError('unexpected ${o.path}'), + ); + final existing = Album.fromJson(albumJson()); + await pumpForm(tester, clientOf(adapter), existing: existing); + + await tester.enterText( + find.byType(TextFormField).first, + '闭幕演出', + ); + await tester.pump(); + + await tapVisible(tester, find.text('Save')); + await tester.pumpAndSettle(); + + final body = adapter.requests.single.body as Map; + expect(body.keys, ['translations']); + expect(body['translations'], >[ + { + 'locale': 'zh-CN', + 'title': '闭幕演出', + 'description': null, + 'venue': '大宴会厅', + 'address': null, + }, + { + 'locale': 'en', + 'title': 'Opening Gala', + 'description': null, + 'venue': null, + 'address': null, + }, + ]); + }); +} diff --git a/apps/mobile/test/pages/album_members_page_test.dart b/apps/mobile/test/pages/album_members_page_test.dart new file mode 100644 index 0000000..ef1854a --- /dev/null +++ b/apps/mobile/test/pages/album_members_page_test.dart @@ -0,0 +1,209 @@ +/// 成员管理页 widget 测试(P3 计划 Task 4)。 +/// +/// 覆盖:列表角色标签/owner 徽标/本人与创建者不可移除;账号前缀搜索 +/// 防抖(300ms、≥2 字符、连续输入合并);邀请载荷与 MEMBER_CONFLICT +/// 本地化;移除确认流。 +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/core/error_codes.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/features/albums/models.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/album_members_page.dart'; + +import '../helpers/fakes.dart'; + +void main() { + late FakeSessionStore store; + late List> members; + + setUp(() { + store = FakeSessionStore() + ..session = Session( + token: 'tok-live', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'staff01', + ); + members = >[ + memberJson( + userId: 'u-owner', + account: 'boss01', + displayName: 'The Owner', + role: 'owner', + ), + memberJson(userId: 'u-1', account: 'staff01', displayName: 'Staff One'), + memberJson(userId: 'u-2', account: 'staff02', displayName: 'Staff Two'), + ]; + }); + + FakeAdapter membersAdapter({ + (int, Object?)? Function(String method, String path)? onMutation, + }) => + FakeAdapter((o) { + if (o.path == '/v1/albums/alb-1/members' && o.method == 'GET') { + return (200, cursorPageBody(members)); + } + if (o.path == '/v1/albums/alb-1/members' && o.method == 'POST') { + final override = onMutation?.call('POST', o.path); + if (override != null) return override; + members = >[ + ...members, + memberJson(userId: 'u-9', account: 'staff09'), + ]; + return (201, memberJson(userId: 'u-9', account: 'staff09')); + } + if (o.path == '/v1/albums/alb-1/members/u-2' && o.method == 'DELETE') { + final override = onMutation?.call('DELETE', o.path); + if (override != null) return override; + members = members + .where((m) => m['userId'] != 'u-2') + .toList(); + return (204, null); + } + if (o.path == '/v1/admin/users' && o.method == 'GET') { + return (200, { + 'items': >[lookupUserJson()], + }); + } + throw StateError('unexpected ${o.method} ${o.path}'); + }); + + ApiClient clientOf(FakeAdapter adapter) => ApiClient( + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + Future pumpMembers(WidgetTester tester, ApiClient client) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + apiClientProvider.overrideWith((ref) => client), + // 本人识别走会话存储:覆写为内存假件(secure storage 插件缺失)。 + sessionStoreProvider.overrideWith((ref) => store), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AlbumMembersPage( + album: Album.fromJson(albumJson()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('列表渲染:角色标签、owner 徽标、本人/创建者无移除按钮', + (tester) async { + await pumpMembers(tester, clientOf(membersAdapter())); + + 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); + }); + + testWidgets('搜索防抖:<2 字符不请求;连续输入合并为最后一次(300ms)', + (tester) async { + final adapter = membersAdapter(); + await pumpMembers(tester, clientOf(adapter)); + + final field = find.byType(TextField); + // 1 个字符:无请求。 + await tester.enterText(field, 's'); + await tester.pump(const Duration(milliseconds: 400)); + expect( + adapter.requests.where((r) => r.path == '/v1/admin/users'), + isEmpty, + ); + + // 连续输入两次,防抖合并为最后一次。 + await tester.enterText(field, 'st'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(field, 'sta'); + await tester.pump(const Duration(milliseconds: 350)); + + final lookups = adapter.requests + .where((r) => r.path == '/v1/admin/users') + .toList(); + expect(lookups, hasLength(1)); + expect(lookups.single.uri.queryParameters['accountPrefix'], 'sta'); + }); + + testWidgets('搜索命中 → 点击邀请 → POST userId 载荷 → 列表刷新 + 提示', + (tester) async { + final adapter = membersAdapter(); + await pumpMembers(tester, clientOf(adapter)); + + await tester.enterText(find.byType(TextField), 'staff09'); + await tester.pump(const Duration(milliseconds: 400)); + + // 搜索框 EditableText 与结果条目同名,按 ListTile 定位结果。 + expect(find.widgetWithText(ListTile, 'staff09'), findsOneWidget); + + await tester.tap(find.text('Invite')); + await tester.pumpAndSettle(); + + final invite = adapter.requests.singleWhere( + (r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/members', + ); + expect(invite.body, {'userId': 'u-9'}); + expect(find.text('Invited'), findsOneWidget); + // 邀请成功后列表重载(staff09 入列)。 + expect(find.text('Collaborator'), findsNWidgets(3)); + }); + + testWidgets('邀请冲突(409 MEMBER_CONFLICT)→ 已是成员文案', (tester) async { + final adapter = membersAdapter( + onMutation: (method, path) => + method == 'POST' ? (409, errorBody(ErrorCodes.memberConflict)) : null, + ); + await pumpMembers(tester, clientOf(adapter)); + + await tester.enterText(find.byType(TextField), 'staff09'); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tap(find.text('Invite')); + await tester.pumpAndSettle(); + + expect(find.text('User is already a member'), findsOneWidget); + }); + + testWidgets('移除成员:确认对话框门控 → DELETE → 列表刷新', (tester) async { + final adapter = membersAdapter(); + await pumpMembers(tester, clientOf(adapter)); + + await tester.tap(find.byIcon(Icons.person_remove_outlined)); + await tester.pumpAndSettle(); + + expect(find.text('Remove this member?'), findsOneWidget); + // 取消不发请求。 + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect( + adapter.requests.any((r) => r.method == 'DELETE'), + isFalse, + ); + + await tester.tap(find.byIcon(Icons.person_remove_outlined)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect( + adapter.requests.any( + (r) => r.method == 'DELETE' && r.path == '/v1/albums/alb-1/members/u-2', + ), + isTrue, + ); + expect(find.text('staff02'), findsNothing); + }); +} diff --git a/apps/mobile/test/pages/album_photos_page_test.dart b/apps/mobile/test/pages/album_photos_page_test.dart new file mode 100644 index 0000000..6ef6bb8 --- /dev/null +++ b/apps/mobile/test/pages/album_photos_page_test.dart @@ -0,0 +1,94 @@ +/// 照片管理占位页 widget 测试(P3 计划 Task 4)。 +/// +/// 覆盖:Task 9 占位横幅、基础列表(可见性/处理状态标签、时区时间)、 +/// 游标加载更多。完整交互由 Task 9 替换。 +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_photos_page.dart'; + +import '../helpers/fakes.dart'; + +void main() { + late FakeSessionStore store; + + setUp(() { + store = FakeSessionStore(); + }); + + ApiClient clientOf(FakeAdapter adapter) => ApiClient( + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + Future pumpPhotos(WidgetTester tester, ApiClient client) async { + await tester.pumpWidget( + ProviderScope( + overrides: [apiClientProvider.overrideWith((ref) => client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AlbumPhotosPage(album: Album.fromJson(albumJson())), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('占位横幅 + 列表标签 + 游标加载第二页', (tester) async { + final page1 = List>.generate( + 20, + (i) => adminPhotoJson( + photoId: 'ph-${(i + 1).toString().padLeft(8, '0')}', + visibility: i % 2 == 0 ? 'published' : 'unpublished', + processingState: i % 2 == 0 ? 'ready' : 'processing', + ), + ); + final page2 = >[ + // 区分度高的 ID:第二页条目渲染后可按标题前缀断言。 + adminPhotoJson(photoId: 'page2item'), + ]; + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + final cursor = o.uri.queryParameters['cursor']; + if (cursor == null) { + return (200, cursorPageBody(page1, nextCursor: 'pc1')); + } + if (cursor == 'pc1') return (200, cursorPageBody(page2)); + throw StateError('unexpected cursor $cursor'); + }); + + await pumpPhotos(tester, clientOf(adapter)); + + // Task 9 占位横幅。 + expect( + find.textContaining('the full experience ships in Task 9'), + findsOneWidget, + ); + // 标签与条目(ListView 懒构建,只断言在渲染的可见条目)。 + expect(find.text('Published'), findsWidgets); + expect(find.text('Unpublished'), findsWidgets); + expect(find.text('Ready'), findsWidgets); + expect(find.text('Processing'), findsWidgets); + expect(find.text('2026/9/18 15:00'), findsWidgets, reason: '相册时区展示'); + + // 滚动加载第二页。 + await tester.drag(find.byType(ListView), const Offset(0, -4000)); + await tester.pumpAndSettle(); + expect( + adapter.requests.last.uri.queryParameters['cursor'], + 'pc1', + ); + // 第二页条目渲染(标题展示 photoId 前 8 位)。 + expect(find.textContaining('page2ite'), findsOneWidget); + }); +} diff --git a/apps/mobile/test/pages/albums_tab_test.dart b/apps/mobile/test/pages/albums_tab_test.dart new file mode 100644 index 0000000..e9c5d85 --- /dev/null +++ b/apps/mobile/test/pages/albums_tab_test.dart @@ -0,0 +1,157 @@ +/// 相册列表页签 widget 测试(P3 计划 Task 4)。 +/// +/// 覆盖:首屏加载与卡片标签行(状态/开关/时区时间/令牌短显)、游标 +/// 加载更多(第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。 +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/core/error_codes.dart'; +import 'package:photo_live/features/albums/providers.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/albums_tab.dart'; + +import '../helpers/fakes.dart'; + +void main() { + late FakeSessionStore store; + + setUp(() { + store = FakeSessionStore(); + }); + + ApiClient clientOf(FakeAdapter adapter) => ApiClient( + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + Future pumpTab(WidgetTester tester, ApiClient client) async { + await tester.pumpWidget( + ProviderScope( + overrides: [apiClientProvider.overrideWith((ref) => client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AlbumsTab(), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('首屏加载 → 卡片渲染标题/状态/开关标签/时区时间/令牌短显', + (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? (200, cursorPageBody([albumJson( + status: 'offline', + uploadEnabled: true, + publicEnabled: false, + downloadEnabled: true, + originalDownloadEnabled: false, + )])) + : throw StateError('unexpected ${o.path}'), + ); + await pumpTab(tester, clientOf(adapter)); + + expect(find.text('开幕晚宴'), findsOneWidget); + expect(find.text('Offline'), findsOneWidget); + // 四个开关标签(色调区分开关,文案一致)。 + expect(find.text('Upload'), findsOneWidget); + expect(find.text('Public'), findsOneWidget); + expect(find.text('Download'), findsOneWidget); + expect(find.text('Original'), findsOneWidget); + // Asia/Shanghai 墙钟 14:30;结束未定。 + expect(find.text('2026/9/18 14:30 ~ Not set'), findsOneWidget); + // 令牌短显(前 10 字符 + 省略号)。 + expect(find.text('sharetoken…'), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget, reason: 'FAB 新建入口'); + }); + + testWidgets('滚动接近底部 → 游标加载第二页(携带 cursor)', (tester) async { + final page1 = List>.generate( + 20, + (i) => albumJson(id: 'alb-${i + 1}'), + ); + final page2 = List>.generate( + 10, + (i) => albumJson(id: 'alb-${i + 21}'), + ); + final adapter = FakeAdapter( + (o) { + if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}'); + final cursor = o.uri.queryParameters['cursor']; + if (cursor == null) return (200, cursorPageBody(page1, nextCursor: 'c1')); + if (cursor == 'c1') return (200, cursorPageBody(page2)); + throw StateError('unexpected cursor $cursor'); + }, + ); + await pumpTab(tester, clientOf(adapter)); + + // 首屏只拉第一页(20 条入 state;ListView 懒构建,不逐条断言渲染)。 + expect(adapter.requests, hasLength(1)); + final container = ProviderScope.containerOf( + tester.element(find.byType(AlbumsTab)), + ); + expect(container.read(albumListProvider).items, hasLength(20)); + + // 上滑触发加载更多。 + await tester.drag(find.byType(ListView), const Offset(0, -2400)); + await tester.pumpAndSettle(); + + expect(adapter.requests, hasLength(2)); + expect( + adapter.requests.last.uri.queryParameters['cursor'], + 'c1', + reason: '第二页携带游标 c1', + ); + expect( + container.read(albumListProvider).items, + hasLength(30), + reason: '两页累积', + ); + }); + + testWidgets('空列表 → 空态文案', (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? (200, cursorPageBody(>[])) + : throw StateError('unexpected ${o.path}'), + ); + await pumpTab(tester, clientOf(adapter)); + + expect( + find.text('No albums yet. Tap "Create album" to get started'), + findsOneWidget, + ); + }); + + testWidgets('首屏错误 → 错误视图,重试后恢复', (tester) async { + var failFirst = true; + final adapter = FakeAdapter( + (o) { + if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}'); + if (failFirst) { + failFirst = false; + return (500, errorBody(ErrorCodes.internalError)); + } + return (200, cursorPageBody([albumJson()])); + }, + ); + await pumpTab(tester, clientOf(adapter)); + + expect( + find.text('Service temporarily unavailable. Please retry later'), + findsOneWidget, + ); + + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + + expect(find.text('开幕晚宴'), findsOneWidget); + }); +} diff --git a/apps/mobile/test/pages/settings_tab_test.dart b/apps/mobile/test/pages/settings_tab_test.dart index 102253a..f7a1c67 100644 --- a/apps/mobile/test/pages/settings_tab_test.dart +++ b/apps/mobile/test/pages/settings_tab_test.dart @@ -69,18 +69,21 @@ void main() { testWidgets('退出登录 → api.logout + 清会话 + 清栈回 /login(服务端失败亦然)', (tester) async { // 服务端撤销失败(500)也照样本地登出——尽力而为语义。 - final adapter = FakeAdapter( - (o) => o.path == '/v1/auth/logout' - ? (500, errorBody('INTERNAL_ERROR')) - : throw StateError('unexpected ${o.path}'), - ); + // 首页「相册」页签(Task 4)进入即自动拉列表,需一并兜底路由。 + final adapter = FakeAdapter((o) { + if (o.path == '/v1/auth/logout') return (500, errorBody('INTERNAL_ERROR')); + if (o.path == '/v1/albums') return (500, errorBody('INTERNAL_ERROR')); + throw StateError('unexpected ${o.path}'); + }); await pumpHome(tester, clientOf(adapter)); await tester.tap(find.text('Sign out')); await tester.pumpAndSettle(); - expect(adapter.requests.single.path, '/v1/auth/logout'); - expect(adapter.requests.single.method, 'POST'); + final logout = adapter.requests.singleWhere( + (r) => r.path == '/v1/auth/logout', + ); + expect(logout.method, 'POST'); expect(store.session, isNull, reason: '本地会话必须清除'); expect(find.byType(LoginPage), findsOneWidget); expect(find.byType(HomePage), findsNothing, reason: '主壳已出栈'); diff --git a/apps/mobile/test/pages/share_qr_dialog_test.dart b/apps/mobile/test/pages/share_qr_dialog_test.dart new file mode 100644 index 0000000..51cca65 --- /dev/null +++ b/apps/mobile/test/pages/share_qr_dialog_test.dart @@ -0,0 +1,72 @@ +/// 分享二维码对话框 widget 测试(P3 计划 Task 4)。 +/// +/// 覆盖:QrImageView 以 viewer URL({VIEWER_BASE}/a/{token})渲染、 +/// URL 文本可见、复制按钮写入剪贴板并提示。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/config.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/share_qr_dialog.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +void main() { + const token = 'sharetoken000000000001'; + + Future pumpDialog(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + // 复制提示走 ScaffoldMessenger:包一层 Scaffold 供 SnackBar 挂载。 + home: const Scaffold( + body: Center(child: ShareQrDialog(publicToken: token)), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('渲染 QrImageView,data 为观众链接;URL 文本可见', (tester) async { + await pumpDialog(tester); + + final url = ApiConfig.viewerAlbumUrl(token); + // qr_flutter 4.1 未公开 QrImageView.data(私有 _data),以同源的 + // SelectableText 文本断言 URL 构造(二维码与文本同用该局部变量)。 + expect(find.byType(QrImageView), findsOneWidget); + expect( + tester.widget(find.byType(SelectableText)).data, + url, + ); + expect(url, 'http://10.0.2.2:5175/a/$token'); + expect(find.text('Scan the code or open the link to view'), findsOneWidget); + }); + + testWidgets('复制按钮 → 剪贴板收到 URL + 已复制提示', (tester) async { + final clipboardCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + clipboardCalls.add(call); + return null; + }, + ); + + await pumpDialog(tester); + await tester.tap(find.text('Copy link')); + await tester.pumpAndSettle(); + + final url = ApiConfig.viewerAlbumUrl(token); + expect( + clipboardCalls.any( + (c) => + c.method == 'Clipboard.setData' && + (c.arguments as Map)['text'] == url, + ), + isTrue, + ); + expect(find.text('Copied'), findsOneWidget); + }); +}