feat(mobile): album management pages with qr share
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
);
|
||||||
@@ -36,5 +36,76 @@
|
|||||||
"errorValidation": "Invalid input. Please check and retry",
|
"errorValidation": "Invalid input. Please check and retry",
|
||||||
"errorNetwork": "Network error. Check your connection and retry",
|
"errorNetwork": "Network error. Check your connection and retry",
|
||||||
"errorNetworkTimeout": "Network timed out. Please 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,432 @@ abstract class AppLocalizations {
|
|||||||
/// In zh, this message translates to:
|
/// In zh, this message translates to:
|
||||||
/// **'服务暂时不可用,请稍后重试'**
|
/// **'服务暂时不可用,请稍后重试'**
|
||||||
String get errorInternal;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -120,4 +120,222 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorInternal =>
|
String get errorInternal =>
|
||||||
'Service temporarily unavailable. Please retry later';
|
'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
|
@override
|
||||||
String get errorInternal => '服务暂时不可用,请稍后重试';
|
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 => '处理失败';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,5 +36,76 @@
|
|||||||
"errorValidation": "输入有误,请检查后重试",
|
"errorValidation": "输入有误,请检查后重试",
|
||||||
"errorNetwork": "网络连接失败,请检查网络后重试",
|
"errorNetwork": "网络连接失败,请检查网络后重试",
|
||||||
"errorNetworkTimeout": "网络超时,请重试",
|
"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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../core/api_client.dart';
|
import '../core/api_client.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import 'albums_tab.dart';
|
||||||
|
|
||||||
/// 主壳:底部导航(相册 / 队列 / 设置)。
|
/// 主壳:底部导航(相册 / 队列 / 设置)。
|
||||||
class HomePage extends StatefulWidget {
|
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:状态机展示、筛选与重试。
|
/// 上传队列(桩)。Task 6/9:状态机展示、筛选与重试。
|
||||||
class QueueTab extends StatelessWidget {
|
class QueueTab extends StatelessWidget {
|
||||||
const QueueTab({super.key});
|
const QueueTab({super.key});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -930,6 +930,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
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:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ dependencies:
|
|||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
# l10n(与 flutter_localizations 来自 SDK 的约束保持一致)
|
# l10n(与 flutter_localizations 来自 SDK 的约束保持一致)
|
||||||
intl: 0.20.2
|
intl: 0.20.2
|
||||||
|
timezone: ^0.11.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -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>[
|
||||||
|
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'], <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{
|
||||||
|
'locale': 'zh-CN',
|
||||||
|
'title': '开幕晚宴',
|
||||||
|
'description': null,
|
||||||
|
'venue': null,
|
||||||
|
'address': null,
|
||||||
|
},
|
||||||
|
<String, dynamic>{
|
||||||
|
'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(), <String, dynamic>{
|
||||||
|
'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(), <String, dynamic>{'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, <String>['translations']);
|
||||||
|
expect(json['translations'], <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{
|
||||||
|
'locale': 'zh-CN',
|
||||||
|
'title': '闭幕演出',
|
||||||
|
'description': null,
|
||||||
|
'venue': '大宴会厅',
|
||||||
|
'address': null,
|
||||||
|
},
|
||||||
|
<String, dynamic>{
|
||||||
|
'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, <String, dynamic>{'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(), <String, dynamic>{
|
||||||
|
'startAt': '2026-09-18T08:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,12 +30,16 @@ class RecordedRequest {
|
|||||||
const RecordedRequest({
|
const RecordedRequest({
|
||||||
required this.method,
|
required this.method,
|
||||||
required this.path,
|
required this.path,
|
||||||
|
required this.uri,
|
||||||
required this.headers,
|
required this.headers,
|
||||||
required this.body,
|
required this.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String method;
|
final String method;
|
||||||
final String path;
|
final String path;
|
||||||
|
|
||||||
|
/// 完整 URI(含 query,游标/过滤参数断言用)。
|
||||||
|
final Uri uri;
|
||||||
final Map<String, dynamic> headers;
|
final Map<String, dynamic> headers;
|
||||||
final Object? body;
|
final Object? body;
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,7 @@ class FakeAdapter implements HttpClientAdapter {
|
|||||||
RecordedRequest(
|
RecordedRequest(
|
||||||
method: options.method,
|
method: options.method,
|
||||||
path: options.path,
|
path: options.path,
|
||||||
|
uri: options.uri,
|
||||||
headers: Map<String, dynamic>.from(options.headers),
|
headers: Map<String, dynamic>.from(options.headers),
|
||||||
body: options.data,
|
body: options.data,
|
||||||
),
|
),
|
||||||
@@ -98,3 +103,144 @@ Map<String, dynamic> errorBody(String code) => <String, dynamic>{
|
|||||||
'message': 'stub message',
|
'message': 'stub message',
|
||||||
'requestId': 'req-stub',
|
'requestId': 'req-stub',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 相册域 fixtures(P3 计划 Task 4;形状对齐 openapi.yaml schemas)──
|
||||||
|
|
||||||
|
/// 契约 `Album` 响应体(GET/POST /albums、PATCH /albums/{id})。
|
||||||
|
Map<String, dynamic> 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<Map<String, dynamic>>? translations,
|
||||||
|
List<Map<String, dynamic>> categories = const <Map<String, dynamic>>[],
|
||||||
|
}) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'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 ??
|
||||||
|
<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{
|
||||||
|
'locale': 'zh-CN',
|
||||||
|
'title': '开幕晚宴',
|
||||||
|
'description': null,
|
||||||
|
'venue': '大宴会厅',
|
||||||
|
'address': null,
|
||||||
|
},
|
||||||
|
<String, dynamic>{
|
||||||
|
'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<String, dynamic> categoryJson({
|
||||||
|
String id = 'cat-1',
|
||||||
|
String albumId = 'alb-1',
|
||||||
|
int sortOrder = 0,
|
||||||
|
List<Map<String, dynamic>>? translations,
|
||||||
|
}) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': id,
|
||||||
|
'albumId': albumId,
|
||||||
|
'sortOrder': sortOrder,
|
||||||
|
'translations': translations ??
|
||||||
|
<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'locale': 'zh-CN', 'name': '仪式'},
|
||||||
|
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 契约 `Member` 响应体。
|
||||||
|
Map<String, dynamic> memberJson({
|
||||||
|
String userId = 'u-2',
|
||||||
|
String account = 'staff02',
|
||||||
|
String displayName = 'Staff Two',
|
||||||
|
String role = 'collaborator',
|
||||||
|
}) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'userId': userId,
|
||||||
|
'account': account,
|
||||||
|
'displayName': displayName,
|
||||||
|
'role': role,
|
||||||
|
'status': 'active',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 契约 `AdminUserLookupItem` 响应体。
|
||||||
|
Map<String, dynamic> lookupUserJson({
|
||||||
|
String id = 'u-9',
|
||||||
|
String account = 'staff09',
|
||||||
|
String displayName = 'Staff Nine',
|
||||||
|
}) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': id,
|
||||||
|
'account': account,
|
||||||
|
'displayName': displayName,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 契约 `AdminPhoto` 响应体。
|
||||||
|
Map<String, dynamic> adminPhotoJson({
|
||||||
|
String photoId = 'ph-00000001',
|
||||||
|
String albumId = 'alb-1',
|
||||||
|
String visibility = 'published',
|
||||||
|
String processingState = 'ready',
|
||||||
|
int bytes = 123456,
|
||||||
|
}) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'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': <String, dynamic>{
|
||||||
|
'userId': 'u-2',
|
||||||
|
'account': 'staff02',
|
||||||
|
'displayName': 'Staff Two',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 契约游标分页响应体(hasMore 与 nextCursor 成对)。
|
||||||
|
Map<String, dynamic> cursorPageBody(
|
||||||
|
List<Map<String, dynamic>> items, {
|
||||||
|
String? nextCursor,
|
||||||
|
}) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'items': items,
|
||||||
|
'nextCursor': nextCursor,
|
||||||
|
'hasMore': nextCursor != null,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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<Map<String, dynamic>> categories;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
store = FakeSessionStore();
|
||||||
|
categories = <Map<String, dynamic>>[
|
||||||
|
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<void> 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 = <Map<String, dynamic>>[
|
||||||
|
...categories,
|
||||||
|
categoryJson(
|
||||||
|
id: 'cat-new',
|
||||||
|
sortOrder: 5,
|
||||||
|
translations: <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
|
||||||
|
<String, dynamic>{'locale': 'en', 'name': 'Check-in'},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return (201, categoryJson(id: 'cat-new', sortOrder: 5));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await pumpDetail(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
await 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, <String, dynamic>{
|
||||||
|
'sortOrder': 5,
|
||||||
|
'translations': <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
|
||||||
|
<String, dynamic>{'locale': 'en', 'name': 'Check-in'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// 详情重载后列表含新分类。
|
||||||
|
expect(find.text('签到 · Check-in'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('新增分类:名称全空 → 客户端拦截,不发请求', (tester) async {
|
||||||
|
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 = <Map<String, dynamic>>[
|
||||||
|
categoryJson(
|
||||||
|
id: 'cat-1',
|
||||||
|
sortOrder: 2,
|
||||||
|
translations: <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
|
||||||
|
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return (200, categoryJson(id: 'cat-1'));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await pumpDetail(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
await tester.tap(find.text('仪式 · Ceremony'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 预填检查。
|
||||||
|
final zhField = tester.widget<TextFormField>(
|
||||||
|
find.widgetWithText(TextFormField, 'Chinese name'),
|
||||||
|
);
|
||||||
|
expect(zhField.controller!.text, '仪式');
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Chinese name'),
|
||||||
|
'签到墙',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Sort order'),
|
||||||
|
'2',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Save'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(patched, isTrue);
|
||||||
|
// 成功后详情重载会追加 GET,故按方法+路径取 PATCH。
|
||||||
|
final request = adapter.requests.singleWhere(
|
||||||
|
(r) => r.method == 'PATCH' && r.path == '/v1/categories/cat-1',
|
||||||
|
);
|
||||||
|
expect(request.body, <String, dynamic>{
|
||||||
|
'sortOrder': 2,
|
||||||
|
'translations': <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
|
||||||
|
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(find.text('签到墙 · Ceremony'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('删除分类:确认对话框 → DELETE → 列表移除', (tester) async {
|
||||||
|
final adapter = detailAdapter(
|
||||||
|
onCategoryMutation: (method, path) {
|
||||||
|
if (method == 'DELETE') {
|
||||||
|
categories = <Map<String, dynamic>>[];
|
||||||
|
return (204, null);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await pumpDetail(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
await 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> 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<void> 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, <String, dynamic>{
|
||||||
|
'translations': <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{
|
||||||
|
'locale': 'zh-CN',
|
||||||
|
'title': '开幕晚宴',
|
||||||
|
'description': null,
|
||||||
|
'venue': null,
|
||||||
|
'address': null,
|
||||||
|
},
|
||||||
|
<String, dynamic>{
|
||||||
|
'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<TextFormField>(
|
||||||
|
find.byType(TextFormField).first,
|
||||||
|
);
|
||||||
|
expect(titleField.controller!.text, '开幕晚宴');
|
||||||
|
// 开关初值与原相册一致。
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<SwitchListTile>(
|
||||||
|
find.widgetWithText(SwitchListTile, 'Allow uploads'),
|
||||||
|
)
|
||||||
|
.value,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<SwitchListTile>(
|
||||||
|
find.widgetWithText(SwitchListTile, 'Publicly visible'),
|
||||||
|
)
|
||||||
|
.value,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<SwitchListTile>(
|
||||||
|
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<FilledButton>(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, <String, dynamic>{
|
||||||
|
'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, <String, dynamic>{'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<String, dynamic>;
|
||||||
|
expect(body.keys, <String>['translations']);
|
||||||
|
expect(body['translations'], <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{
|
||||||
|
'locale': 'zh-CN',
|
||||||
|
'title': '闭幕演出',
|
||||||
|
'description': null,
|
||||||
|
'venue': '大宴会厅',
|
||||||
|
'address': null,
|
||||||
|
},
|
||||||
|
<String, dynamic>{
|
||||||
|
'locale': 'en',
|
||||||
|
'title': 'Opening Gala',
|
||||||
|
'description': null,
|
||||||
|
'venue': null,
|
||||||
|
'address': null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Map<String, dynamic>> members;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
store = FakeSessionStore()
|
||||||
|
..session = Session(
|
||||||
|
token: 'tok-live',
|
||||||
|
expiresAt: DateTime.utc(2099, 1, 1),
|
||||||
|
account: 'staff01',
|
||||||
|
);
|
||||||
|
members = <Map<String, dynamic>>[
|
||||||
|
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 = <Map<String, dynamic>>[
|
||||||
|
...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, <String, dynamic>{
|
||||||
|
'items': <Map<String, dynamic>>[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<void> 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, <String, dynamic>{'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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> 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<Map<String, dynamic>>.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 = <Map<String, dynamic>>[
|
||||||
|
// 区分度高的 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> 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<Map<String, dynamic>>.generate(
|
||||||
|
20,
|
||||||
|
(i) => albumJson(id: 'alb-${i + 1}'),
|
||||||
|
);
|
||||||
|
final page2 = List<Map<String, dynamic>>.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(<Map<String, dynamic>>[]))
|
||||||
|
: 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -69,18 +69,21 @@ void main() {
|
|||||||
testWidgets('退出登录 → api.logout + 清会话 + 清栈回 /login(服务端失败亦然)',
|
testWidgets('退出登录 → api.logout + 清会话 + 清栈回 /login(服务端失败亦然)',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
// 服务端撤销失败(500)也照样本地登出——尽力而为语义。
|
// 服务端撤销失败(500)也照样本地登出——尽力而为语义。
|
||||||
final adapter = FakeAdapter(
|
// 首页「相册」页签(Task 4)进入即自动拉列表,需一并兜底路由。
|
||||||
(o) => o.path == '/v1/auth/logout'
|
final adapter = FakeAdapter((o) {
|
||||||
? (500, errorBody('INTERNAL_ERROR'))
|
if (o.path == '/v1/auth/logout') return (500, errorBody('INTERNAL_ERROR'));
|
||||||
: throw StateError('unexpected ${o.path}'),
|
if (o.path == '/v1/albums') return (500, errorBody('INTERNAL_ERROR'));
|
||||||
);
|
throw StateError('unexpected ${o.path}');
|
||||||
|
});
|
||||||
await pumpHome(tester, clientOf(adapter));
|
await pumpHome(tester, clientOf(adapter));
|
||||||
|
|
||||||
await tester.tap(find.text('Sign out'));
|
await tester.tap(find.text('Sign out'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(adapter.requests.single.path, '/v1/auth/logout');
|
final logout = adapter.requests.singleWhere(
|
||||||
expect(adapter.requests.single.method, 'POST');
|
(r) => r.path == '/v1/auth/logout',
|
||||||
|
);
|
||||||
|
expect(logout.method, 'POST');
|
||||||
expect(store.session, isNull, reason: '本地会话必须清除');
|
expect(store.session, isNull, reason: '本地会话必须清除');
|
||||||
expect(find.byType(LoginPage), findsOneWidget);
|
expect(find.byType(LoginPage), findsOneWidget);
|
||||||
expect(find.byType(HomePage), findsNothing, reason: '主壳已出栈');
|
expect(find.byType(HomePage), findsNothing, reason: '主壳已出栈');
|
||||||
|
|||||||
@@ -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<void> 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<SelectableText>(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 = <MethodCall>[];
|
||||||
|
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<String, dynamic>)['text'] == url,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(find.text('Copied'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user