feat(mobile): album management pages with qr share

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 01:56:38 +08:00
parent 8be21b856b
commit bbea1dd364
27 changed files with 5485 additions and 28 deletions
@@ -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<AlbumTranslationDraft> get drafts => <AlbumTranslationDraft>[zh, en];
/// 客户端预检;返回按序去重的错误码(空列表 = 通过)。
List<AlbumFormError> validate() {
final errors = <AlbumFormError>{};
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<AlbumTranslation> 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<AlbumTranslation> a,
List<AlbumTranslation> b,
) {
if (a.length != b.length) return false;
final byLocale = <String, AlbumTranslation>{
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();
}
@@ -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();
}
@@ -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 <AlbumSummary>[],
this.nextCursor,
this.hasMore = false,
this.loading = false,
this.loadingMore = false,
this.loadMoreFailed = false,
this.errorCode,
});
/// 已加载条目(跨页累积)。
final List<AlbumSummary> 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<AlbumListState> {
/// 每页条数(契约列表端点通用上限内取 20)。
static const int pageSize = 20;
@override
AlbumListState build() {
// microtask:确保不在 build 执行期内改 state。
Future<void>.microtask(refresh);
return const AlbumListState(loading: true);
}
/// 重置到第一页(下拉刷新/数据变更后同步)。
Future<void> 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<void> 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: <AlbumSummary>[...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, AlbumListState>(
AlbumListNotifier.new,
);
+72 -1
View File
@@ -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"
}
+426
View File
@@ -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
@@ -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';
}
@@ -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 => '处理失败';
}
+72 -1
View File
@@ -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": "处理失败"
}
@@ -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<AlbumDetailPage> createState() => _AlbumDetailPageState();
}
class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
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<void> _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<void> _openEdit() async {
final album = _album;
if (album == null) return;
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => AlbumFormPage(existing: album),
),
);
if (changed == true) {
await _load();
ref.read(albumListProvider.notifier).refresh();
}
}
Future<void> _openMembers() async {
final album = _album;
if (album == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumMembersPage(album: album),
),
);
// 成员变更不影响详情数据,回来无需刷新。
}
Future<void> _openPhotos() async {
final album = _album;
if (album == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumPhotosPage(album: album),
),
);
}
void _shareQr() {
final album = _album;
if (album == null) return;
showDialog<void>(
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<void> _deleteCategory(Category category) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(l10n.delete),
content: Text(l10n.deleteConfirmCategory),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.delete),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(apiClientProvider)
.deleteCategory(category.id);
await _load();
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(e))),
);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
}
}
Future<void> _openCategoryDialog([Category? existing]) async {
final album = _album;
if (album == null) return;
final changed = await showDialog<bool>(
context: context,
builder: (_) => CategoryEditDialog(albumId: album.id, existing: existing),
);
if (changed == true) await _load();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final album = _album;
final title = album == null ? l10n.albumDetail : _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<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(l10n.retry),
),
],
),
);
}
}
/// 信息卡:标题 + 状态、默认语言内容(描述/场地/地址)、时区时间区间、
/// 公开令牌、四开关标签。
class _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<void> Function(Category category) onDelete;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.albumCategories,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
IconButton(
tooltip: l10n.categoryAdd,
onPressed: onAdd,
icon: const Icon(Icons.add_circle_outline),
),
],
),
if (album.categories.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.categoryEmpty,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
)
else
Card(
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<CategoryEditDialog> createState() => _CategoryEditDialogState();
}
class _CategoryEditDialogState extends ConsumerState<CategoryEditDialog> {
late final TextEditingController _sortOrder =
TextEditingController(text: (widget.existing?.sortOrder ?? 0).toString());
late final TextEditingController _zhName = TextEditingController(
text: widget.existing?.translations
.where((t) => t.locale == 'zh-CN')
.firstOrNull
?.name ??
'',
);
late final TextEditingController _enName = TextEditingController(
text: widget.existing?.translations
.where((t) => t.locale == 'en')
.firstOrNull
?.name ??
'',
);
String? _error;
bool _submitting = false;
@override
void dispose() {
_sortOrder.dispose();
_zhName.dispose();
_enName.dispose();
super.dispose();
}
Future<void> _submit() async {
final l10n = AppLocalizations.of(context);
final zh = _zhName.text.trim();
final en = _enName.text.trim();
if (zh.isEmpty && en.isEmpty) {
setState(() => _error = l10n.categoryNameRequired);
return;
}
final sortOrder = int.tryParse(_sortOrder.text.trim());
if (sortOrder == null) {
setState(() => _error = l10n.categorySortOrderInvalid);
return;
}
setState(() {
_error = null;
_submitting = true;
});
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final translations = <CategoryTranslation>[
if (zh.isNotEmpty) CategoryTranslation(locale: 'zh-CN', name: zh),
if (en.isNotEmpty) CategoryTranslation(locale: 'en', name: en),
];
try {
final api = ref.read(apiClientProvider);
if (widget.existing == null) {
await api.createCategory(
widget.albumId,
CategoryCreateRequest(
sortOrder: sortOrder,
translations: translations,
),
);
} else {
await api.updateCategory(
widget.existing!.id,
CategoryUpdateRequest(
sortOrder: sortOrder,
translations: translations,
),
);
}
navigator.pop(true);
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(e))),
);
if (mounted) setState(() => _submitting = false);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(widget.existing == null ? l10n.categoryAdd : l10n.categoryEdit),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _sortOrder,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l10n.categorySortOrder,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _zhName,
decoration: InputDecoration(
labelText: l10n.categoryNameZh,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _enName,
decoration: InputDecoration(
labelText: l10n.categoryNameEn,
border: const OutlineInputBorder(),
),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
actions: [
TextButton(
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.save),
),
],
);
}
}
+460
View File
@@ -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<AlbumFormPage> createState() => _AlbumFormPageState();
}
class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
static const String _zh = 'zh-CN';
static const String _en = 'en';
late final List<TextEditingController> _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<AlbumFormError> _errors = const <AlbumFormError>[];
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<void> _submit() async {
final draft = _currentDraft();
final errors = draft.validate();
if (errors.isNotEmpty) {
setState(() => _errors = errors);
return;
}
setState(() {
_submitting = true;
_errors = const <AlbumFormError>[];
});
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<void> _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<String>(
segments: [
ButtonSegment(value: _zh, label: Text(l10n.localeChinese)),
ButtonSegment(value: _en, label: Text(l10n.localeEnglish)),
],
selected: {_defaultLocale},
onSelectionChanged: (selection) =>
setState(() => _defaultLocale = selection.first),
),
const SizedBox(height: 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;
}
}
}
@@ -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<AlbumMembersPage> createState() => _AlbumMembersPageState();
}
class _AlbumMembersPageState extends ConsumerState<AlbumMembersPage> {
static const int _pageSize = 50;
List<Member> _items = const <Member>[];
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<AdminUserLookupItem>? _results;
@override
void initState() {
super.initState();
_loadSelfAccount();
_reload();
}
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
super.dispose();
}
Future<void> _loadSelfAccount() async {
try {
final session = await ref.read(sessionStoreProvider).read();
if (mounted) {
setState(() => _selfAccount = session?.account);
}
} on Object {
// 存储读取失败不阻断页面(移除按钮保护回退服务端 403/409 裁决)。
}
}
Future<void> _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<void> _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 = <Member>[..._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<void> _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<void> _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<void> _remove(Member member) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(l10n.removeMember),
content: Text(l10n.removeMemberConfirm),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.delete),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(apiClientProvider)
.removeMember(widget.album.id, member.userId);
await _reload();
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(e))),
);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
}
}
bool _canRemove(Member member) =>
member.role != MemberRole.owner && member.account != _selfAccount;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.albumMembers)),
body: Column(
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,
),
),
],
),
);
}
}
@@ -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<AlbumPhotosPage> createState() => _AlbumPhotosPageState();
}
class _AlbumPhotosPageState extends ConsumerState<AlbumPhotosPage> {
static const int _pageSize = 20;
List<AdminPhoto> _items = const <AdminPhoto>[];
String? _nextCursor;
bool _hasMore = false;
bool _loading = true;
bool _loadingMore = false;
String? _errorCode;
@override
void initState() {
super.initState();
_reload();
}
Future<void> _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<void> _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 = <AdminPhoto>[..._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,
),
),
);
}
}
+335
View File
@@ -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<void> _openCreate(BuildContext context, WidgetRef ref) async {
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(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<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(l10n.retry),
),
],
),
);
}
}
/// 空列表视图。
class _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<void>(
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,
),
),
);
}
}
+1 -19
View File
@@ -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<HomePage> {
}
}
/// 相册列表(桩)。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});
+101
View File
@@ -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<void> _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),
),
],
),
],
),
),
);
}
}