feat(mobile): UI-01 upload center, capture and auxiliary pages
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -20,31 +20,29 @@ import 'providers.dart';
|
||||
import 'task_state.dart';
|
||||
import 'task_store.dart';
|
||||
|
||||
/// 状态筛选分组(chip → 状态集合;「全部」= 不过滤)。
|
||||
/// 状态筛选分组(UI-01 §4.4:chip → 状态集合;「全部」= 不过滤)。
|
||||
enum QueueStateFilter {
|
||||
all,
|
||||
preparing,
|
||||
queued,
|
||||
uploading,
|
||||
waitingNetwork,
|
||||
failed,
|
||||
inProgress,
|
||||
needsAttention,
|
||||
completed;
|
||||
|
||||
/// 分组对应的状态集合(空 = 不过滤)。
|
||||
///
|
||||
/// - 进行中 = 引擎正在推进的活任务(准备/排队/传输/确认 + 等待网络
|
||||
/// ——网络等待自动恢复,属系统仍在推进的范畴,归入进行中而非需关注);
|
||||
/// - 需关注 = 需要用户动作的失败(准备失败需重新选图、上传失败需重试);
|
||||
/// - 已完成 = 服务端已受理的终态(cancelled 仅在「全部」可见)。
|
||||
Set<UploadTaskState> get states => switch (this) {
|
||||
QueueStateFilter.all => const <UploadTaskState>{},
|
||||
QueueStateFilter.preparing => const <UploadTaskState>{
|
||||
QueueStateFilter.inProgress => const <UploadTaskState>{
|
||||
UploadTaskState.preparing,
|
||||
},
|
||||
QueueStateFilter.queued => const <UploadTaskState>{UploadTaskState.queued},
|
||||
QueueStateFilter.uploading => const <UploadTaskState>{
|
||||
UploadTaskState.queued,
|
||||
UploadTaskState.uploading,
|
||||
UploadTaskState.uploadedConfirming,
|
||||
},
|
||||
QueueStateFilter.waitingNetwork => const <UploadTaskState>{
|
||||
UploadTaskState.waitingNetwork,
|
||||
},
|
||||
QueueStateFilter.failed => const <UploadTaskState>{
|
||||
QueueStateFilter.needsAttention => const <UploadTaskState>{
|
||||
UploadTaskState.preparationFailed,
|
||||
UploadTaskState.retryableFailed,
|
||||
},
|
||||
@@ -237,6 +235,60 @@ final taskCountsProvider =
|
||||
TaskCountsNotifier.new,
|
||||
);
|
||||
|
||||
/// 摘要计数(UI-01 §4.4):**当前筛选范围内**(相册 × 时间)的任务数聚合
|
||||
/// ——与列表同口径,范围标注由队列页摘要卡展示;仍走 SQL 聚合
|
||||
/// ([TaskStore.countsByState]),不整表载入内存。写通知防抖刷新。
|
||||
class QueueSummaryNotifier extends Notifier<Map<UploadTaskState, int>> {
|
||||
StreamSubscription<void>? _changesSub;
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
Map<UploadTaskState, int> build() {
|
||||
final store = ref.watch(taskStoreProvider);
|
||||
// 会话与筛选变化(相册/时间维度改变摘要口径)都触发重建。
|
||||
ref.watch(sessionAccountProvider);
|
||||
ref.watch(queueFilterProvider);
|
||||
_changesSub?.cancel();
|
||||
_debounce?.cancel();
|
||||
_changesSub = store.changes.listen((_) => _scheduleReload());
|
||||
ref.onDispose(_dispose);
|
||||
Future<void>.microtask(_reload);
|
||||
return <UploadTaskState, int>{
|
||||
for (final state in UploadTaskState.values) state: 0,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _reload() async {
|
||||
final account = ref.read(sessionAccountProvider).value;
|
||||
if (account == null) return;
|
||||
final filter = ref.read(queueFilterProvider);
|
||||
final counts = await ref.read(taskStoreProvider).countsByState(
|
||||
account,
|
||||
albumId: filter.albumId,
|
||||
since: filter.time.since(DateTime.now()),
|
||||
);
|
||||
if (ref.mounted) state = counts;
|
||||
}
|
||||
|
||||
void _scheduleReload() {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(kQueueRefreshDebounce, _reload);
|
||||
}
|
||||
|
||||
void _dispose() {
|
||||
_changesSub?.cancel();
|
||||
_changesSub = null;
|
||||
_debounce?.cancel();
|
||||
_debounce = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 摘要计数 Provider(当前筛选范围口径;键覆盖全部状态,无任务计 0)。
|
||||
final queueSummaryProvider =
|
||||
NotifierProvider<QueueSummaryNotifier, Map<UploadTaskState, int>>(
|
||||
QueueSummaryNotifier.new,
|
||||
);
|
||||
|
||||
/// 未完成(待传)计数:准备中/待传/上传中/确认中/等待网络之和。
|
||||
int pendingCountOf(Map<UploadTaskState, int> counts) =>
|
||||
counts[UploadTaskState.preparing]! +
|
||||
@@ -250,6 +302,23 @@ int failedCountOf(Map<UploadTaskState, int> counts) =>
|
||||
counts[UploadTaskState.preparationFailed]! +
|
||||
counts[UploadTaskState.retryableFailed]!;
|
||||
|
||||
// ── 摘要行计数(UI-01 §4.4:按任务数统计,不与字节进度混口径)──────────
|
||||
|
||||
/// 摘要「已完成」:服务端已受理的任务数(终态记录,不含已取消)。
|
||||
int completedSummaryOf(Map<UploadTaskState, int> counts) =>
|
||||
counts[UploadTaskState.completed]!;
|
||||
|
||||
/// 摘要「上传中」:正在传输/确认的任务数。
|
||||
int uploadingSummaryOf(Map<UploadTaskState, int> counts) =>
|
||||
counts[UploadTaskState.uploading]! +
|
||||
counts[UploadTaskState.uploadedConfirming]!;
|
||||
|
||||
/// 摘要「等待中」:准备/排队/等待网络的任务数(等待调度,非用户错误)。
|
||||
int waitingSummaryOf(Map<UploadTaskState, int> counts) =>
|
||||
counts[UploadTaskState.preparing]! +
|
||||
counts[UploadTaskState.queued]! +
|
||||
counts[UploadTaskState.waitingNetwork]!;
|
||||
|
||||
/// 「未结束」计数(底部导航「上传」角标,UI-01 §4.1):待传(
|
||||
/// [pendingCountOf])+ 可重试失败——均为引擎待推进或用户需关注的活任务;
|
||||
/// 准备失败/取消/完成不计入。账号隔离由 TaskStore.countsByState 硬守护。
|
||||
|
||||
@@ -407,12 +407,27 @@ class TaskStore {
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// 账号内各状态任务数(队列 UI 徽章;全部状态都会出现,无任务计 0)。
|
||||
Future<Map<UploadTaskState, int>> countsByState(String accountId) async {
|
||||
final rows = await (_db.selectOnly(_db.uploadTasks)
|
||||
..addColumns([_db.uploadTasks.state, _db.uploadTasks.id.count()])
|
||||
..where(_db.uploadTasks.accountId.equals(accountId))
|
||||
..groupBy([_db.uploadTasks.state]))
|
||||
/// 账号内各状态任务数(队列 UI 徽章/摘要;全部状态都会出现,无任务计
|
||||
/// 0)。可选 albumId/since 与 [listByAccount] 同语义(UI-01 §4.4 摘要按
|
||||
/// 当前筛选范围聚合,仍走 SQL COUNT,不整表载入内存)。
|
||||
Future<Map<UploadTaskState, int>> countsByState(
|
||||
String accountId, {
|
||||
String? albumId,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
final query = _db.selectOnly(_db.uploadTasks)
|
||||
..addColumns([_db.uploadTasks.state, _db.uploadTasks.id.count()])
|
||||
..where(_db.uploadTasks.accountId.equals(accountId));
|
||||
if (albumId != null) {
|
||||
query.where(_db.uploadTasks.albumId.equals(albumId));
|
||||
}
|
||||
if (since != null) {
|
||||
query.where(
|
||||
_db.uploadTasks.createdAt.isBiggerOrEqualValue(since.toUtc()),
|
||||
);
|
||||
}
|
||||
query.groupBy([_db.uploadTasks.state]);
|
||||
final rows = await query
|
||||
.map((row) => MapEntry(
|
||||
row.readWithConverter(_db.uploadTasks.state)!,
|
||||
row.read(_db.uploadTasks.id.count())!,
|
||||
|
||||
@@ -7,9 +7,20 @@
|
||||
"password": "Password",
|
||||
"albums": "Albums",
|
||||
"albumDetail": "Album details",
|
||||
"queue": "Upload queue",
|
||||
"queue": "Uploads",
|
||||
"settings": "Settings",
|
||||
"language": "Language",
|
||||
"settingsSectionAccount": "Account",
|
||||
"settingsSectionLanguage": "Language",
|
||||
"settingsSectionSecurity": "Security",
|
||||
"settingsSectionUploadHelp": "Upload help",
|
||||
"settingsSectionAbout": "About",
|
||||
"settingsSignedInAccount": "Currently signed-in account",
|
||||
"settingsVersion": "Version",
|
||||
"settingsUploadHelpTitle": "How background uploads work",
|
||||
"settingsUploadHelpBody": "While the app stays in the background, the system keeps uploads running within its battery limits. If you force quit the app or the system stops it, unfinished tasks pause and resume the next time you open the app. Tasks waiting for the network start automatically once a connection is available — no manual retry needed.",
|
||||
"logoutConfirmTitle": "Sign out?",
|
||||
"logoutConfirmBody": "Local upload tasks are preserved on this device and will resume when you sign back in.",
|
||||
"localeFollowSystem": "Follow system",
|
||||
"localeChinese": "中文",
|
||||
"localeEnglish": "English",
|
||||
@@ -46,6 +57,9 @@
|
||||
"loadMoreFailed": "Failed to load more. Tap to retry",
|
||||
"albumCreate": "Create album",
|
||||
"albumEdit": "Edit album",
|
||||
"albumSectionEventInfo": "Event info",
|
||||
"albumSectionContent": "Multilingual content",
|
||||
"albumSectionAccess": "Access settings",
|
||||
"albumTitle": "Title",
|
||||
"albumDescription": "Description",
|
||||
"albumVenue": "Venue",
|
||||
@@ -102,6 +116,7 @@
|
||||
"qrHint": "Scan the code or open the link to view",
|
||||
"copyLink": "Copy link",
|
||||
"copiedToClipboard": "Copied",
|
||||
"openInBrowser": "Open in browser",
|
||||
"photosTask9Placeholder": "Photo management is a placeholder; the full experience ships in Task 9",
|
||||
"albumPhotosEmpty": "No photos yet",
|
||||
"visibilityPublished": "Published",
|
||||
@@ -114,9 +129,9 @@
|
||||
"procReady": "Ready",
|
||||
"procProcessingFailed": "Processing failed",
|
||||
"queueStubHint": "Local upload queue and continuous capture arrive in Task 9",
|
||||
"serverHistoryEntry": "View server upload history",
|
||||
"serverHistoryTitle": "Server upload history",
|
||||
"serverHistoryBanner": "Server-side history (cross-device) — for this device's pending uploads see the Queue tab",
|
||||
"serverHistoryEntry": "Server history",
|
||||
"serverHistoryTitle": "Server history (cross-device)",
|
||||
"serverHistoryBanner": "Upload records from all of your devices. The local queue is on the Uploads tab.",
|
||||
"serverHistoryEmpty": "No upload sessions yet",
|
||||
"uploadStatePending": "Pending",
|
||||
"uploadStateConfirmed": "Confirmed",
|
||||
@@ -125,12 +140,26 @@
|
||||
"thisDevice": "This device",
|
||||
"queueAlbumFilterAll": "All albums",
|
||||
"queueStateAll": "All",
|
||||
"queueStatePreparing": "Preparing",
|
||||
"queueStateQueued": "Queued",
|
||||
"queueStateUploading": "Uploading",
|
||||
"queueStateWaitingNetwork": "Waiting for network",
|
||||
"queueStateFailed": "Failed",
|
||||
"queueStateCompleted": "Done",
|
||||
"queueStateInProgress": "In progress",
|
||||
"queueStateNeedsAttention": "Needs attention",
|
||||
"queueStateCompleted": "Completed",
|
||||
"queueSummaryCompleted": "completed",
|
||||
"queueSummaryUploading": "uploading",
|
||||
"queueSummaryWaiting": "waiting",
|
||||
"queueSummaryNeedAttention": "need attention",
|
||||
"queueRowUploadingPercent": "Uploading {percent}%",
|
||||
"@queueRowUploadingPercent": { "placeholders": { "percent": { "type": "int" } } },
|
||||
"queueRowConfirming": "Confirming upload",
|
||||
"queueRowWaitingUpload": "Waiting to upload",
|
||||
"queueRowPreparing": "Preparing photo",
|
||||
"queueWaitingNetworkHint": "Resumes automatically when the network recovers",
|
||||
"queueRowFileUnavailable": "File unavailable",
|
||||
"queueActionSelectAgain": "Select again",
|
||||
"queueActionRemoveRecord": "Remove record",
|
||||
"queueRemoveRecordTitle": "Remove this record?",
|
||||
"queueRemoveRecordBody": "This only removes the local task record. Photos already uploaded are not deleted from the cloud.",
|
||||
"queueRemoveRecordConfirm": "Remove",
|
||||
"queueRetryAllScope": "Only failed uploads on this device are retried. Tasks waiting for the network recover automatically.",
|
||||
"queueTimeAll": "Any time",
|
||||
"queueTimeToday": "Today",
|
||||
"queueTimeLast7Days": "Last 7 days",
|
||||
@@ -165,6 +194,9 @@
|
||||
"@captureFailedBadge": { "placeholders": { "count": { "type": "int" } } },
|
||||
"capturePickFromGallery": "Pick from gallery",
|
||||
"captureShutter": "Capture",
|
||||
"captureQueuedBadge": "Queued {count}",
|
||||
"@captureQueuedBadge": { "placeholders": { "count": { "type": "int" } } },
|
||||
"captureEnqueuedFeedback": "Added to queue",
|
||||
"captureExitTitle": "Leave shooting?",
|
||||
"captureExitBody": "{count} photo(s) are still uploading. Tasks will continue in the background after you leave.",
|
||||
"@captureExitBody": { "placeholders": { "count": { "type": "int" } } },
|
||||
|
||||
@@ -143,7 +143,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @queue.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传队列'**
|
||||
/// **'上传中心'**
|
||||
String get queue;
|
||||
|
||||
/// No description provided for @settings.
|
||||
@@ -158,6 +158,72 @@ abstract class AppLocalizations {
|
||||
/// **'语言'**
|
||||
String get language;
|
||||
|
||||
/// No description provided for @settingsSectionAccount.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'账号'**
|
||||
String get settingsSectionAccount;
|
||||
|
||||
/// No description provided for @settingsSectionLanguage.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'语言'**
|
||||
String get settingsSectionLanguage;
|
||||
|
||||
/// No description provided for @settingsSectionSecurity.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'安全'**
|
||||
String get settingsSectionSecurity;
|
||||
|
||||
/// No description provided for @settingsSectionUploadHelp.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传帮助'**
|
||||
String get settingsSectionUploadHelp;
|
||||
|
||||
/// No description provided for @settingsSectionAbout.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'关于'**
|
||||
String get settingsSectionAbout;
|
||||
|
||||
/// No description provided for @settingsSignedInAccount.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'当前登录账号'**
|
||||
String get settingsSignedInAccount;
|
||||
|
||||
/// No description provided for @settingsVersion.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'版本'**
|
||||
String get settingsVersion;
|
||||
|
||||
/// No description provided for @settingsUploadHelpTitle.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'后台上传说明'**
|
||||
String get settingsUploadHelpTitle;
|
||||
|
||||
/// No description provided for @settingsUploadHelpBody.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'应用在后台期间,系统会在省电限制内尽量保持上传;强制退出或被系统结束后,未完成任务会暂停,下次打开应用时继续。等待网络的任务会在网络恢复后自动开始,无需手动重试。'**
|
||||
String get settingsUploadHelpBody;
|
||||
|
||||
/// No description provided for @logoutConfirmTitle.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'退出登录?'**
|
||||
String get logoutConfirmTitle;
|
||||
|
||||
/// No description provided for @logoutConfirmBody.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'本机的上传任务会保留,重新登录后将继续上传。'**
|
||||
String get logoutConfirmBody;
|
||||
|
||||
/// No description provided for @localeFollowSystem.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
@@ -368,6 +434,24 @@ abstract class AppLocalizations {
|
||||
/// **'编辑相册'**
|
||||
String get albumEdit;
|
||||
|
||||
/// No description provided for @albumSectionEventInfo.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'活动信息'**
|
||||
String get albumSectionEventInfo;
|
||||
|
||||
/// No description provided for @albumSectionContent.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'多语言内容'**
|
||||
String get albumSectionContent;
|
||||
|
||||
/// No description provided for @albumSectionAccess.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'访问设置'**
|
||||
String get albumSectionAccess;
|
||||
|
||||
/// No description provided for @albumTitle.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
@@ -704,6 +788,12 @@ abstract class AppLocalizations {
|
||||
/// **'已复制'**
|
||||
String get copiedToClipboard;
|
||||
|
||||
/// No description provided for @openInBrowser.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'在浏览器中打开'**
|
||||
String get openInBrowser;
|
||||
|
||||
/// No description provided for @photosTask9Placeholder.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
@@ -779,19 +869,19 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @serverHistoryEntry.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'查看服务端上传历史'**
|
||||
/// **'服务端历史'**
|
||||
String get serverHistoryEntry;
|
||||
|
||||
/// No description provided for @serverHistoryTitle.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'服务端上传历史'**
|
||||
/// **'服务端历史(跨设备)'**
|
||||
String get serverHistoryTitle;
|
||||
|
||||
/// No description provided for @serverHistoryBanner.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'服务端历史(跨设备)——本机待传队列请看「队列」页'**
|
||||
/// **'展示全部设备的服务端上传记录;本机待传队列在「上传」页。'**
|
||||
String get serverHistoryBanner;
|
||||
|
||||
/// No description provided for @serverHistoryEmpty.
|
||||
@@ -842,35 +932,17 @@ abstract class AppLocalizations {
|
||||
/// **'全部'**
|
||||
String get queueStateAll;
|
||||
|
||||
/// No description provided for @queueStatePreparing.
|
||||
/// No description provided for @queueStateInProgress.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'准备中'**
|
||||
String get queueStatePreparing;
|
||||
/// **'进行中'**
|
||||
String get queueStateInProgress;
|
||||
|
||||
/// No description provided for @queueStateQueued.
|
||||
/// No description provided for @queueStateNeedsAttention.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'待传'**
|
||||
String get queueStateQueued;
|
||||
|
||||
/// No description provided for @queueStateUploading.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传中'**
|
||||
String get queueStateUploading;
|
||||
|
||||
/// No description provided for @queueStateWaitingNetwork.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'等待网络'**
|
||||
String get queueStateWaitingNetwork;
|
||||
|
||||
/// No description provided for @queueStateFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'失败'**
|
||||
String get queueStateFailed;
|
||||
/// **'需关注'**
|
||||
String get queueStateNeedsAttention;
|
||||
|
||||
/// No description provided for @queueStateCompleted.
|
||||
///
|
||||
@@ -878,6 +950,102 @@ abstract class AppLocalizations {
|
||||
/// **'已完成'**
|
||||
String get queueStateCompleted;
|
||||
|
||||
/// No description provided for @queueSummaryCompleted.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已完成'**
|
||||
String get queueSummaryCompleted;
|
||||
|
||||
/// No description provided for @queueSummaryUploading.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传中'**
|
||||
String get queueSummaryUploading;
|
||||
|
||||
/// No description provided for @queueSummaryWaiting.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'等待中'**
|
||||
String get queueSummaryWaiting;
|
||||
|
||||
/// No description provided for @queueSummaryNeedAttention.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'需关注'**
|
||||
String get queueSummaryNeedAttention;
|
||||
|
||||
/// No description provided for @queueRowUploadingPercent.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传中 {percent}%'**
|
||||
String queueRowUploadingPercent(int percent);
|
||||
|
||||
/// No description provided for @queueRowConfirming.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'正在确认上传'**
|
||||
String get queueRowConfirming;
|
||||
|
||||
/// No description provided for @queueRowWaitingUpload.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'等待上传'**
|
||||
String get queueRowWaitingUpload;
|
||||
|
||||
/// No description provided for @queueRowPreparing.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'正在准备照片'**
|
||||
String get queueRowPreparing;
|
||||
|
||||
/// No description provided for @queueWaitingNetworkHint.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'网络恢复后自动继续'**
|
||||
String get queueWaitingNetworkHint;
|
||||
|
||||
/// No description provided for @queueRowFileUnavailable.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'文件不可用'**
|
||||
String get queueRowFileUnavailable;
|
||||
|
||||
/// No description provided for @queueActionSelectAgain.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'重新选择'**
|
||||
String get queueActionSelectAgain;
|
||||
|
||||
/// No description provided for @queueActionRemoveRecord.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'移除记录'**
|
||||
String get queueActionRemoveRecord;
|
||||
|
||||
/// No description provided for @queueRemoveRecordTitle.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'移除这条记录?'**
|
||||
String get queueRemoveRecordTitle;
|
||||
|
||||
/// No description provided for @queueRemoveRecordBody.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'仅移除本机的任务记录,不会删除云端已上传的照片。'**
|
||||
String get queueRemoveRecordBody;
|
||||
|
||||
/// No description provided for @queueRemoveRecordConfirm.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'移除'**
|
||||
String get queueRemoveRecordConfirm;
|
||||
|
||||
/// No description provided for @queueRetryAllScope.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'仅重试本机的上传失败任务;等待网络的任务会自动恢复。'**
|
||||
String get queueRetryAllScope;
|
||||
|
||||
/// No description provided for @queueTimeAll.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
@@ -1064,6 +1232,18 @@ abstract class AppLocalizations {
|
||||
/// **'拍摄'**
|
||||
String get captureShutter;
|
||||
|
||||
/// No description provided for @captureQueuedBadge.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'待传 {count}'**
|
||||
String captureQueuedBadge(int count);
|
||||
|
||||
/// No description provided for @captureEnqueuedFeedback.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已加入队列'**
|
||||
String get captureEnqueuedFeedback;
|
||||
|
||||
/// No description provided for @captureExitTitle.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
|
||||
@@ -30,7 +30,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get albumDetail => 'Album details';
|
||||
|
||||
@override
|
||||
String get queue => 'Upload queue';
|
||||
String get queue => 'Uploads';
|
||||
|
||||
@override
|
||||
String get settings => 'Settings';
|
||||
@@ -38,6 +38,41 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get language => 'Language';
|
||||
|
||||
@override
|
||||
String get settingsSectionAccount => 'Account';
|
||||
|
||||
@override
|
||||
String get settingsSectionLanguage => 'Language';
|
||||
|
||||
@override
|
||||
String get settingsSectionSecurity => 'Security';
|
||||
|
||||
@override
|
||||
String get settingsSectionUploadHelp => 'Upload help';
|
||||
|
||||
@override
|
||||
String get settingsSectionAbout => 'About';
|
||||
|
||||
@override
|
||||
String get settingsSignedInAccount => 'Currently signed-in account';
|
||||
|
||||
@override
|
||||
String get settingsVersion => 'Version';
|
||||
|
||||
@override
|
||||
String get settingsUploadHelpTitle => 'How background uploads work';
|
||||
|
||||
@override
|
||||
String get settingsUploadHelpBody =>
|
||||
'While the app stays in the background, the system keeps uploads running within its battery limits. If you force quit the app or the system stops it, unfinished tasks pause and resume the next time you open the app. Tasks waiting for the network start automatically once a connection is available — no manual retry needed.';
|
||||
|
||||
@override
|
||||
String get logoutConfirmTitle => 'Sign out?';
|
||||
|
||||
@override
|
||||
String get logoutConfirmBody =>
|
||||
'Local upload tasks are preserved on this device and will resume when you sign back in.';
|
||||
|
||||
@override
|
||||
String get localeFollowSystem => 'Follow system';
|
||||
|
||||
@@ -148,6 +183,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get albumEdit => 'Edit album';
|
||||
|
||||
@override
|
||||
String get albumSectionEventInfo => 'Event info';
|
||||
|
||||
@override
|
||||
String get albumSectionContent => 'Multilingual content';
|
||||
|
||||
@override
|
||||
String get albumSectionAccess => 'Access settings';
|
||||
|
||||
@override
|
||||
String get albumTitle => 'Title';
|
||||
|
||||
@@ -319,6 +363,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get copiedToClipboard => 'Copied';
|
||||
|
||||
@override
|
||||
String get openInBrowser => 'Open in browser';
|
||||
|
||||
@override
|
||||
String get photosTask9Placeholder =>
|
||||
'Photo management is a placeholder; the full experience ships in Task 9';
|
||||
@@ -358,14 +405,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
'Local upload queue and continuous capture arrive in Task 9';
|
||||
|
||||
@override
|
||||
String get serverHistoryEntry => 'View server upload history';
|
||||
String get serverHistoryEntry => 'Server history';
|
||||
|
||||
@override
|
||||
String get serverHistoryTitle => 'Server upload history';
|
||||
String get serverHistoryTitle => 'Server history (cross-device)';
|
||||
|
||||
@override
|
||||
String get serverHistoryBanner =>
|
||||
'Server-side history (cross-device) — for this device\'s pending uploads see the Queue tab';
|
||||
'Upload records from all of your devices. The local queue is on the Uploads tab.';
|
||||
|
||||
@override
|
||||
String get serverHistoryEmpty => 'No upload sessions yet';
|
||||
@@ -392,22 +439,66 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get queueStateAll => 'All';
|
||||
|
||||
@override
|
||||
String get queueStatePreparing => 'Preparing';
|
||||
String get queueStateInProgress => 'In progress';
|
||||
|
||||
@override
|
||||
String get queueStateQueued => 'Queued';
|
||||
String get queueStateNeedsAttention => 'Needs attention';
|
||||
|
||||
@override
|
||||
String get queueStateUploading => 'Uploading';
|
||||
String get queueStateCompleted => 'Completed';
|
||||
|
||||
@override
|
||||
String get queueStateWaitingNetwork => 'Waiting for network';
|
||||
String get queueSummaryCompleted => 'completed';
|
||||
|
||||
@override
|
||||
String get queueStateFailed => 'Failed';
|
||||
String get queueSummaryUploading => 'uploading';
|
||||
|
||||
@override
|
||||
String get queueStateCompleted => 'Done';
|
||||
String get queueSummaryWaiting => 'waiting';
|
||||
|
||||
@override
|
||||
String get queueSummaryNeedAttention => 'need attention';
|
||||
|
||||
@override
|
||||
String queueRowUploadingPercent(int percent) {
|
||||
return 'Uploading $percent%';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueRowConfirming => 'Confirming upload';
|
||||
|
||||
@override
|
||||
String get queueRowWaitingUpload => 'Waiting to upload';
|
||||
|
||||
@override
|
||||
String get queueRowPreparing => 'Preparing photo';
|
||||
|
||||
@override
|
||||
String get queueWaitingNetworkHint =>
|
||||
'Resumes automatically when the network recovers';
|
||||
|
||||
@override
|
||||
String get queueRowFileUnavailable => 'File unavailable';
|
||||
|
||||
@override
|
||||
String get queueActionSelectAgain => 'Select again';
|
||||
|
||||
@override
|
||||
String get queueActionRemoveRecord => 'Remove record';
|
||||
|
||||
@override
|
||||
String get queueRemoveRecordTitle => 'Remove this record?';
|
||||
|
||||
@override
|
||||
String get queueRemoveRecordBody =>
|
||||
'This only removes the local task record. Photos already uploaded are not deleted from the cloud.';
|
||||
|
||||
@override
|
||||
String get queueRemoveRecordConfirm => 'Remove';
|
||||
|
||||
@override
|
||||
String get queueRetryAllScope =>
|
||||
'Only failed uploads on this device are retried. Tasks waiting for the network recover automatically.';
|
||||
|
||||
@override
|
||||
String get queueTimeAll => 'Any time';
|
||||
@@ -514,6 +605,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get captureShutter => 'Capture';
|
||||
|
||||
@override
|
||||
String captureQueuedBadge(int count) {
|
||||
return 'Queued $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get captureEnqueuedFeedback => 'Added to queue';
|
||||
|
||||
@override
|
||||
String get captureExitTitle => 'Leave shooting?';
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get albumDetail => '相册详情';
|
||||
|
||||
@override
|
||||
String get queue => '上传队列';
|
||||
String get queue => '上传中心';
|
||||
|
||||
@override
|
||||
String get settings => '设置';
|
||||
@@ -38,6 +38,40 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get language => '语言';
|
||||
|
||||
@override
|
||||
String get settingsSectionAccount => '账号';
|
||||
|
||||
@override
|
||||
String get settingsSectionLanguage => '语言';
|
||||
|
||||
@override
|
||||
String get settingsSectionSecurity => '安全';
|
||||
|
||||
@override
|
||||
String get settingsSectionUploadHelp => '上传帮助';
|
||||
|
||||
@override
|
||||
String get settingsSectionAbout => '关于';
|
||||
|
||||
@override
|
||||
String get settingsSignedInAccount => '当前登录账号';
|
||||
|
||||
@override
|
||||
String get settingsVersion => '版本';
|
||||
|
||||
@override
|
||||
String get settingsUploadHelpTitle => '后台上传说明';
|
||||
|
||||
@override
|
||||
String get settingsUploadHelpBody =>
|
||||
'应用在后台期间,系统会在省电限制内尽量保持上传;强制退出或被系统结束后,未完成任务会暂停,下次打开应用时继续。等待网络的任务会在网络恢复后自动开始,无需手动重试。';
|
||||
|
||||
@override
|
||||
String get logoutConfirmTitle => '退出登录?';
|
||||
|
||||
@override
|
||||
String get logoutConfirmBody => '本机的上传任务会保留,重新登录后将继续上传。';
|
||||
|
||||
@override
|
||||
String get localeFollowSystem => '跟随系统';
|
||||
|
||||
@@ -145,6 +179,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get albumEdit => '编辑相册';
|
||||
|
||||
@override
|
||||
String get albumSectionEventInfo => '活动信息';
|
||||
|
||||
@override
|
||||
String get albumSectionContent => '多语言内容';
|
||||
|
||||
@override
|
||||
String get albumSectionAccess => '访问设置';
|
||||
|
||||
@override
|
||||
String get albumTitle => '标题';
|
||||
|
||||
@@ -313,6 +356,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get copiedToClipboard => '已复制';
|
||||
|
||||
@override
|
||||
String get openInBrowser => '在浏览器中打开';
|
||||
|
||||
@override
|
||||
String get photosTask9Placeholder => '照片管理为占位版本,完整功能将在任务 9 上线';
|
||||
|
||||
@@ -350,13 +396,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get queueStubHint => '本机待传队列与连拍管理将在任务 9 上线';
|
||||
|
||||
@override
|
||||
String get serverHistoryEntry => '查看服务端上传历史';
|
||||
String get serverHistoryEntry => '服务端历史';
|
||||
|
||||
@override
|
||||
String get serverHistoryTitle => '服务端上传历史';
|
||||
String get serverHistoryTitle => '服务端历史(跨设备)';
|
||||
|
||||
@override
|
||||
String get serverHistoryBanner => '服务端历史(跨设备)——本机待传队列请看「队列」页';
|
||||
String get serverHistoryBanner => '展示全部设备的服务端上传记录;本机待传队列在「上传」页。';
|
||||
|
||||
@override
|
||||
String get serverHistoryEmpty => '暂无上传记录';
|
||||
@@ -383,23 +429,64 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get queueStateAll => '全部';
|
||||
|
||||
@override
|
||||
String get queueStatePreparing => '准备中';
|
||||
String get queueStateInProgress => '进行中';
|
||||
|
||||
@override
|
||||
String get queueStateQueued => '待传';
|
||||
|
||||
@override
|
||||
String get queueStateUploading => '上传中';
|
||||
|
||||
@override
|
||||
String get queueStateWaitingNetwork => '等待网络';
|
||||
|
||||
@override
|
||||
String get queueStateFailed => '失败';
|
||||
String get queueStateNeedsAttention => '需关注';
|
||||
|
||||
@override
|
||||
String get queueStateCompleted => '已完成';
|
||||
|
||||
@override
|
||||
String get queueSummaryCompleted => '已完成';
|
||||
|
||||
@override
|
||||
String get queueSummaryUploading => '上传中';
|
||||
|
||||
@override
|
||||
String get queueSummaryWaiting => '等待中';
|
||||
|
||||
@override
|
||||
String get queueSummaryNeedAttention => '需关注';
|
||||
|
||||
@override
|
||||
String queueRowUploadingPercent(int percent) {
|
||||
return '上传中 $percent%';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueRowConfirming => '正在确认上传';
|
||||
|
||||
@override
|
||||
String get queueRowWaitingUpload => '等待上传';
|
||||
|
||||
@override
|
||||
String get queueRowPreparing => '正在准备照片';
|
||||
|
||||
@override
|
||||
String get queueWaitingNetworkHint => '网络恢复后自动继续';
|
||||
|
||||
@override
|
||||
String get queueRowFileUnavailable => '文件不可用';
|
||||
|
||||
@override
|
||||
String get queueActionSelectAgain => '重新选择';
|
||||
|
||||
@override
|
||||
String get queueActionRemoveRecord => '移除记录';
|
||||
|
||||
@override
|
||||
String get queueRemoveRecordTitle => '移除这条记录?';
|
||||
|
||||
@override
|
||||
String get queueRemoveRecordBody => '仅移除本机的任务记录,不会删除云端已上传的照片。';
|
||||
|
||||
@override
|
||||
String get queueRemoveRecordConfirm => '移除';
|
||||
|
||||
@override
|
||||
String get queueRetryAllScope => '仅重试本机的上传失败任务;等待网络的任务会自动恢复。';
|
||||
|
||||
@override
|
||||
String get queueTimeAll => '全部时间';
|
||||
|
||||
@@ -499,6 +586,14 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get captureShutter => '拍摄';
|
||||
|
||||
@override
|
||||
String captureQueuedBadge(int count) {
|
||||
return '待传 $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get captureEnqueuedFeedback => '已加入队列';
|
||||
|
||||
@override
|
||||
String get captureExitTitle => '退出拍摄?';
|
||||
|
||||
|
||||
@@ -7,9 +7,20 @@
|
||||
"password": "密码",
|
||||
"albums": "相册",
|
||||
"albumDetail": "相册详情",
|
||||
"queue": "上传队列",
|
||||
"queue": "上传中心",
|
||||
"settings": "设置",
|
||||
"language": "语言",
|
||||
"settingsSectionAccount": "账号",
|
||||
"settingsSectionLanguage": "语言",
|
||||
"settingsSectionSecurity": "安全",
|
||||
"settingsSectionUploadHelp": "上传帮助",
|
||||
"settingsSectionAbout": "关于",
|
||||
"settingsSignedInAccount": "当前登录账号",
|
||||
"settingsVersion": "版本",
|
||||
"settingsUploadHelpTitle": "后台上传说明",
|
||||
"settingsUploadHelpBody": "应用在后台期间,系统会在省电限制内尽量保持上传;强制退出或被系统结束后,未完成任务会暂停,下次打开应用时继续。等待网络的任务会在网络恢复后自动开始,无需手动重试。",
|
||||
"logoutConfirmTitle": "退出登录?",
|
||||
"logoutConfirmBody": "本机的上传任务会保留,重新登录后将继续上传。",
|
||||
"localeFollowSystem": "跟随系统",
|
||||
"localeChinese": "中文",
|
||||
"localeEnglish": "English",
|
||||
@@ -46,6 +57,9 @@
|
||||
"loadMoreFailed": "加载更多失败,点击重试",
|
||||
"albumCreate": "新建相册",
|
||||
"albumEdit": "编辑相册",
|
||||
"albumSectionEventInfo": "活动信息",
|
||||
"albumSectionContent": "多语言内容",
|
||||
"albumSectionAccess": "访问设置",
|
||||
"albumTitle": "标题",
|
||||
"albumDescription": "描述",
|
||||
"albumVenue": "场地",
|
||||
@@ -102,6 +116,7 @@
|
||||
"qrHint": "扫码或打开链接观看",
|
||||
"copyLink": "复制链接",
|
||||
"copiedToClipboard": "已复制",
|
||||
"openInBrowser": "在浏览器中打开",
|
||||
"photosTask9Placeholder": "照片管理为占位版本,完整功能将在任务 9 上线",
|
||||
"albumPhotosEmpty": "暂无照片",
|
||||
"visibilityPublished": "已发布",
|
||||
@@ -114,9 +129,9 @@
|
||||
"procReady": "就绪",
|
||||
"procProcessingFailed": "处理失败",
|
||||
"queueStubHint": "本机待传队列与连拍管理将在任务 9 上线",
|
||||
"serverHistoryEntry": "查看服务端上传历史",
|
||||
"serverHistoryTitle": "服务端上传历史",
|
||||
"serverHistoryBanner": "服务端历史(跨设备)——本机待传队列请看「队列」页",
|
||||
"serverHistoryEntry": "服务端历史",
|
||||
"serverHistoryTitle": "服务端历史(跨设备)",
|
||||
"serverHistoryBanner": "展示全部设备的服务端上传记录;本机待传队列在「上传」页。",
|
||||
"serverHistoryEmpty": "暂无上传记录",
|
||||
"uploadStatePending": "待完成",
|
||||
"uploadStateConfirmed": "已确认",
|
||||
@@ -125,12 +140,26 @@
|
||||
"thisDevice": "本设备",
|
||||
"queueAlbumFilterAll": "全部相册",
|
||||
"queueStateAll": "全部",
|
||||
"queueStatePreparing": "准备中",
|
||||
"queueStateQueued": "待传",
|
||||
"queueStateUploading": "上传中",
|
||||
"queueStateWaitingNetwork": "等待网络",
|
||||
"queueStateFailed": "失败",
|
||||
"queueStateInProgress": "进行中",
|
||||
"queueStateNeedsAttention": "需关注",
|
||||
"queueStateCompleted": "已完成",
|
||||
"queueSummaryCompleted": "已完成",
|
||||
"queueSummaryUploading": "上传中",
|
||||
"queueSummaryWaiting": "等待中",
|
||||
"queueSummaryNeedAttention": "需关注",
|
||||
"queueRowUploadingPercent": "上传中 {percent}%",
|
||||
"@queueRowUploadingPercent": { "placeholders": { "percent": { "type": "int" } } },
|
||||
"queueRowConfirming": "正在确认上传",
|
||||
"queueRowWaitingUpload": "等待上传",
|
||||
"queueRowPreparing": "正在准备照片",
|
||||
"queueWaitingNetworkHint": "网络恢复后自动继续",
|
||||
"queueRowFileUnavailable": "文件不可用",
|
||||
"queueActionSelectAgain": "重新选择",
|
||||
"queueActionRemoveRecord": "移除记录",
|
||||
"queueRemoveRecordTitle": "移除这条记录?",
|
||||
"queueRemoveRecordBody": "仅移除本机的任务记录,不会删除云端已上传的照片。",
|
||||
"queueRemoveRecordConfirm": "移除",
|
||||
"queueRetryAllScope": "仅重试本机的上传失败任务;等待网络的任务会自动恢复。",
|
||||
"queueTimeAll": "全部时间",
|
||||
"queueTimeToday": "今天",
|
||||
"queueTimeLast7Days": "近7天",
|
||||
@@ -165,6 +194,9 @@
|
||||
"@captureFailedBadge": { "placeholders": { "count": { "type": "int" } } },
|
||||
"capturePickFromGallery": "从相册选择",
|
||||
"captureShutter": "拍摄",
|
||||
"captureQueuedBadge": "待传 {count}",
|
||||
"@captureQueuedBadge": { "placeholders": { "count": { "type": "int" } } },
|
||||
"captureEnqueuedFeedback": "已加入队列",
|
||||
"captureExitTitle": "退出拍摄?",
|
||||
"captureExitBody": "还有 {count} 张照片未上传,退出后任务将在后台继续。",
|
||||
"@captureExitBody": { "placeholders": { "count": { "type": "int" } } },
|
||||
|
||||
@@ -204,7 +204,10 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
if (album == null) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => ShareQrDialog(publicToken: album.publicToken),
|
||||
builder: (_) => ShareQrDialog(
|
||||
publicToken: album.publicToken,
|
||||
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/// 相册创建/编辑表单页(P3 计划 Task 4)。
|
||||
/// 相册创建/编辑表单页(UI-01 §4.5 分组重构,P3 计划 Task 4 基础)。
|
||||
///
|
||||
/// zh-CN / en 双语 tab(标题/描述/场地/地址)、默认语言分段选择、
|
||||
/// 日期时间选择器(开始必填;结束可选、可显式清除)、时区文本(缺省
|
||||
/// Asia/Shanghai)、开关组:创建仅 download/original(契约缺省
|
||||
/// upload/public=true 不可创建时覆盖),编辑为四开关独立。
|
||||
/// 三分组(SectionHeader):活动信息(开始/结束时间 + 时区)/ 多语言
|
||||
/// 内容(zh-CN · en 双语 tab:标题/描述/场地/地址按语言各一份——契约
|
||||
/// translations 的场地/地址是逐语言字段,不合并为共享输入)/ 访问设置
|
||||
/// (默认语言 + 开关组:创建仅 download/original,编辑四开关独立)。
|
||||
/// 保存按钮固定在 bottomNavigationBar——键盘打开时常驻可达(§4.5)。
|
||||
/// 编辑提交经 [AlbumFormDraft.toUpdatePatch] 最小化 PATCH(仅变更字段);
|
||||
/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情。
|
||||
library;
|
||||
@@ -18,6 +19,8 @@ import '../features/albums/album_format.dart';
|
||||
import '../features/albums/album_form.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/section_header.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 创建([existing] 为 null)/ 编辑相册表单。
|
||||
class AlbumFormPage extends ConsumerStatefulWidget {
|
||||
@@ -220,7 +223,6 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
// DefaultTabController 仅为 TabBar 提供 controller(内容区非
|
||||
// TabBarView,而是按 _tabIndex 手动切换,避免两 tab 表单失焦重建)。
|
||||
return DefaultTabController(
|
||||
@@ -228,116 +230,129 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
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),
|
||||
],
|
||||
),
|
||||
// 保存按钮固定底部:键盘打开时常驻可达(§4.5)。
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: FilledButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(_isCreate ? l10n.create : l10n.save),
|
||||
),
|
||||
),
|
||||
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) ...[
|
||||
body: Form(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── 分组 1:活动信息 ────────────────────────────────
|
||||
SectionHeader(title: l10n.albumSectionEventInfo),
|
||||
const SizedBox(height: kSpace12),
|
||||
_dateTimeRow(isStart: true),
|
||||
_dateTimeRow(isStart: false),
|
||||
const SizedBox(height: 8),
|
||||
..._errors.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
_errorText(l10n, e),
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
TextFormField(
|
||||
controller: _timezone,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(labelText: l10n.albumTimezone),
|
||||
),
|
||||
const SizedBox(height: kSpace24),
|
||||
|
||||
// ── 分组 2:多语言内容(tab 切换,另一语言按需填写)──
|
||||
SectionHeader(title: l10n.albumSectionContent),
|
||||
const SizedBox(height: kSpace8),
|
||||
TabBar(
|
||||
onTap: (index) => setState(() => _tabIndex = index),
|
||||
tabs: [
|
||||
Tab(text: l10n.localeChinese),
|
||||
Tab(text: l10n.localeEnglish),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: kSpace12),
|
||||
if (_tabIndex == 0)
|
||||
_translationFields(
|
||||
title: _zhTitle,
|
||||
description: _zhDescription,
|
||||
venue: _zhVenue,
|
||||
address: _zhAddress,
|
||||
isDefault: _defaultLocale == _zh,
|
||||
)
|
||||
else
|
||||
_translationFields(
|
||||
title: _enTitle,
|
||||
description: _enDescription,
|
||||
venue: _enVenue,
|
||||
address: _enAddress,
|
||||
isDefault: _defaultLocale == _en,
|
||||
),
|
||||
const SizedBox(height: kSpace24),
|
||||
|
||||
// ── 分组 3:访问设置 ────────────────────────────────
|
||||
SectionHeader(title: l10n.albumSectionAccess),
|
||||
const SizedBox(height: kSpace12),
|
||||
Text(l10n.albumDefaultLocale),
|
||||
const SizedBox(height: kSpace8),
|
||||
SegmentedButton<String>(
|
||||
segments: [
|
||||
ButtonSegment(value: _zh, label: Text(l10n.localeChinese)),
|
||||
ButtonSegment(value: _en, label: Text(l10n.localeEnglish)),
|
||||
],
|
||||
selected: {_defaultLocale},
|
||||
onSelectionChanged: (selection) =>
|
||||
setState(() => _defaultLocale = selection.first),
|
||||
),
|
||||
const SizedBox(height: kSpace16),
|
||||
if (!_isCreate) ...[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchUpload),
|
||||
value: _uploadEnabled,
|
||||
onChanged: (v) => setState(() => _uploadEnabled = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchPublic),
|
||||
value: _publicEnabled,
|
||||
onChanged: (v) => setState(() => _publicEnabled = v),
|
||||
),
|
||||
],
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchDownload),
|
||||
value: _downloadEnabled,
|
||||
onChanged: (v) => setState(() => _downloadEnabled = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchOriginalDownload),
|
||||
value: _originalDownloadEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _originalDownloadEnabled = v),
|
||||
),
|
||||
if (_errors.isNotEmpty) ...[
|
||||
const SizedBox(height: kSpace8),
|
||||
..._errors.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
_errorText(l10n, e),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: kSpace24),
|
||||
],
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -361,7 +376,6 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: isDefault ? '${l10n.albumTitle} *' : l10n.albumTitle,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -369,28 +383,19 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
controller: description,
|
||||
onChanged: (_) => setState(() {}),
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumDescription,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.albumDescription),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: venue,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumVenue,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.albumVenue),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: address,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumAddress,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.albumAddress),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -412,7 +417,6 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: isStart ? l10n.albumStartAt : l10n.albumEndAtOptional,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: value == null
|
||||
? const Icon(Icons.edit_calendar_outlined)
|
||||
: Row(
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show HapticFeedback;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/albums/models.dart';
|
||||
@@ -124,6 +125,10 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
/// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。
|
||||
Future<void> _shotTail = Future<void>.value();
|
||||
|
||||
/// 入队成功短暂反馈(150-220ms 轻量动效档,§3.1)。
|
||||
bool _showEnqueuedFeedback = false;
|
||||
Timer? _feedbackTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -132,6 +137,7 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_feedbackTimer?.cancel();
|
||||
_source?.dispose().ignore();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -170,18 +176,30 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
_shotTail = _shotTail.then((_) async {
|
||||
final photo = await source.capture();
|
||||
if (!mounted) return;
|
||||
await enqueueForUpload(
|
||||
final enqueued = await enqueueForUpload(
|
||||
ref,
|
||||
accountId: widget.target.accountId,
|
||||
albumId: widget.target.albumId,
|
||||
categoryId: categoryId,
|
||||
sourcePaths: [photo.path],
|
||||
);
|
||||
// 入队成功:短暂反馈(轻触 + 200ms 浮层),不逐张弹确认(§4.5)。
|
||||
if (enqueued > 0 && mounted) _flashEnqueued();
|
||||
}).catchError((Object _) {
|
||||
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
|
||||
});
|
||||
}
|
||||
|
||||
/// 入队成功反馈:触觉轻点 + 顶部短暂浮层(200ms 后自动消退)。
|
||||
void _flashEnqueued() {
|
||||
HapticFeedback.selectionClick();
|
||||
_feedbackTimer?.cancel();
|
||||
setState(() => _showEnqueuedFeedback = true);
|
||||
_feedbackTimer = Timer(const Duration(milliseconds: 200), () {
|
||||
if (mounted) setState(() => _showEnqueuedFeedback = false);
|
||||
});
|
||||
}
|
||||
|
||||
/// 相册多选入队(目标 = 当前相册与分类)。
|
||||
Future<void> _pickFromGallery() async {
|
||||
final paths = await ref.read(multiImagePickerProvider)();
|
||||
@@ -264,7 +282,7 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildCameraArea(l10n),
|
||||
// 常驻信息层:相册 + 分类 + 待传/失败徽章(R17/O01)。
|
||||
// 常驻信息层:相册 + 分类 + 失败徽章(R17/O01)。
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -273,15 +291,56 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
categories: categories,
|
||||
categoryId: _categoryId,
|
||||
onCategorySelected: _switchCategory,
|
||||
pending: pending,
|
||||
failed: failed,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 入队成功短暂反馈浮层(200ms 自动消退,不逐张弹确认)。
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showEnqueuedFeedback ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
l10n.captureEnqueuedFeedback,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _buildBottomBar(l10n),
|
||||
bottomNavigationBar: _buildBottomBar(l10n, pending),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -312,9 +371,12 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(AppLocalizations l10n) {
|
||||
Widget _buildBottomBar(AppLocalizations l10n, int pending) {
|
||||
return BottomAppBar(
|
||||
color: Colors.black87,
|
||||
// 默认高 56 放不下「快门 + 待传徽章」竖排(内容 ≈105,BottomAppBar
|
||||
// 内部再占 24),显式加高避免溢出。
|
||||
height: 132,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
@@ -324,7 +386,22 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
color: Colors.white),
|
||||
onPressed: _pickFromGallery,
|
||||
),
|
||||
_ShutterButton(onPressed: _shoot, enabled: _phase == _CameraPhase.ready),
|
||||
// 快门 + 入队计数徽章(§4.5:底部清晰快门与入队计数)。
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ShutterButton(
|
||||
onPressed: _shoot,
|
||||
enabled: _phase == _CameraPhase.ready,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_Badge(
|
||||
label: l10n.captureQueuedBadge(pending),
|
||||
color: Colors.white24,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
// 占位对齐(快门居中)。
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
@@ -335,15 +412,14 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
|
||||
// ── 信息层组件 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 待传/失败
|
||||
/// 徽章。
|
||||
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 失败徽章
|
||||
/// (待传计数在快门旁常驻,见底部栏)。
|
||||
class _TargetOverlay extends StatelessWidget {
|
||||
const _TargetOverlay({
|
||||
required this.albumTitle,
|
||||
required this.categories,
|
||||
required this.categoryId,
|
||||
required this.onCategorySelected,
|
||||
required this.pending,
|
||||
required this.failed,
|
||||
});
|
||||
|
||||
@@ -351,7 +427,6 @@ class _TargetOverlay extends StatelessWidget {
|
||||
final List<Category> categories;
|
||||
final String? categoryId;
|
||||
final void Function(String?) onCategorySelected;
|
||||
final int pending;
|
||||
final int failed;
|
||||
|
||||
@override
|
||||
@@ -376,19 +451,12 @@ class _TargetOverlay extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_Badge(
|
||||
label: l10n.capturePendingBadge(pending),
|
||||
color: Colors.white24,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
if (failed > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
if (failed > 0)
|
||||
_Badge(
|
||||
label: l10n.captureFailedBadge(failed),
|
||||
color: Colors.red,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,9 +6,18 @@ import '../core/api_client.dart';
|
||||
import '../features/uploads/engine_scope.dart';
|
||||
import '../features/uploads/queue_controller.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/section_header.dart';
|
||||
import '../ui/tokens.dart';
|
||||
import 'albums_tab.dart';
|
||||
import 'queue_tab.dart';
|
||||
|
||||
/// 应用展示版本(About 分组):与 pubspec version 对齐的缺省值,
|
||||
/// `--dart-define=APP_VERSION=...` 覆盖(未引入 package_info,零新依赖)。
|
||||
const String kAppVersion = String.fromEnvironment(
|
||||
'APP_VERSION',
|
||||
defaultValue: '1.0.0',
|
||||
);
|
||||
|
||||
/// 主壳:底部导航「相册 / 上传 / 设置」(UI-01 §4.1)。
|
||||
///
|
||||
/// IndexedStack 保持三页签状态;「上传」页签角标 = 当前账号未结束任务数
|
||||
@@ -83,7 +92,9 @@ class _UploadTabIcon extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页(P3 Task 3:修改密码入口 + 退出登录;Task 10:语言切换)。
|
||||
/// 设置页(UI-01 §4.5 分组重构):账号 / 语言 / 安全(改密 + 退出)/
|
||||
/// 上传帮助 / 关于(版本)。退出登录确认弹窗明确「本机任务保留,重新
|
||||
/// 登录后继续」——不承诺退出后后台继续上传。
|
||||
class SettingsTab extends ConsumerWidget {
|
||||
const SettingsTab({super.key});
|
||||
|
||||
@@ -130,6 +141,30 @@ class SettingsTab extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 退出确认:明确本地任务保留、重新登录后继续(不承诺后台继续上传)。
|
||||
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.logoutConfirmTitle),
|
||||
content: Text(l10n.logoutConfirmBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
await _logout(context, ref);
|
||||
}
|
||||
|
||||
Future<void> _logout(BuildContext context, WidgetRef ref) async {
|
||||
// api.logout 尽力而为(服务端撤销失败也照常本地清会话——客户端内已处理)。
|
||||
await ref.read(apiClientProvider).logout();
|
||||
@@ -142,17 +177,48 @@ class SettingsTab extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final choice = ref.watch(localeSettingsProvider);
|
||||
final account = ref.watch(sessionAccountProvider).value;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.settings)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: kSpace8)
|
||||
.copyWith(bottom: kSpace16),
|
||||
children: [
|
||||
// ── 账号 ────────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, kSpace8,
|
||||
),
|
||||
child: SectionHeader(title: l10n.settingsSectionAccount),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outline),
|
||||
title: Text(account ?? l10n.notSet),
|
||||
subtitle: Text(l10n.settingsSignedInAccount),
|
||||
),
|
||||
// ── 语言 ────────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, kSpace8,
|
||||
),
|
||||
child: SectionHeader(title: l10n.settingsSectionLanguage),
|
||||
),
|
||||
// 分组已名「语言」,行标题直接显示当前档位(避免同名重复)。
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language),
|
||||
title: Text(l10n.language),
|
||||
subtitle: Text(_choiceLabel(l10n, choice)),
|
||||
title: Text(_choiceLabel(l10n, choice)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _pickLocale(context, ref, choice),
|
||||
),
|
||||
// ── 安全:改密 + 退出(确认后清会话) ────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, kSpace8,
|
||||
),
|
||||
child: SectionHeader(title: l10n.settingsSectionSecurity),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.password_outlined),
|
||||
title: Text(l10n.passwordChangeTitle),
|
||||
@@ -161,7 +227,48 @@ class SettingsTab extends ConsumerWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout),
|
||||
title: Text(l10n.logout),
|
||||
onTap: () => _logout(context, ref),
|
||||
onTap: () => _confirmLogout(context, ref),
|
||||
),
|
||||
// ── 上传帮助:系统限制 / 强退 / 网络等待三分态说明 ────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, kSpace8,
|
||||
),
|
||||
child: SectionHeader(title: l10n.settingsSectionUploadHelp),
|
||||
),
|
||||
ExpansionTile(
|
||||
leading: const Icon(Icons.help_outline),
|
||||
title: Text(l10n.settingsUploadHelpTitle),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, 0, kSpace16, kSpace16,
|
||||
),
|
||||
child: Text(
|
||||
l10n.settingsUploadHelpBody,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── 关于:版本 ──────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, kSpace8,
|
||||
),
|
||||
child: SectionHeader(title: l10n.settingsSectionAbout),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(l10n.settingsVersion),
|
||||
trailing: Text(
|
||||
kAppVersion,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/// 登录页(UI-01 §4.5 主题统一,P3 计划 Task 3 基础)。
|
||||
///
|
||||
/// 账号/密码表单 → [ApiClient.login](会话由客户端自动持久化到
|
||||
/// SessionStore)→ 成功 pushReplacement /home;失败以内联 StateBadge
|
||||
/// (图标 + 文案 + 错误色)展示错误码本地化文案(AUTH_RATE_LIMITED 文案
|
||||
/// 自带「稍后再试」;网络/校验错误各有专属文案)。提交期间按钮进入
|
||||
/// 加载态防重复提交。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -6,13 +15,10 @@ import '../core/app_exception.dart';
|
||||
import '../core/error_l10n.dart';
|
||||
import '../features/uploads/engine_scope.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/state_badge.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 登录页(P3 计划 Task 3)。
|
||||
///
|
||||
/// 账号/密码表单 → [ApiClient.login](会话由客户端自动持久化到
|
||||
/// SessionStore)→ 成功 pushReplacement /home;失败按错误码本地化提示
|
||||
/// (AUTH_RATE_LIMITED 文案自带「稍后再试」;网络/校验错误各有专属文案)。
|
||||
/// 提交期间按钮进入加载态防重复提交。
|
||||
/// 登录页。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@@ -27,6 +33,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
|
||||
bool _submitting = false;
|
||||
|
||||
/// 最近一次提交失败文案(内联 StateBadge 展示;null = 无错误)。
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accountController.dispose();
|
||||
@@ -36,9 +45,11 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _submitting = true);
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
await ref.read(apiClientProvider).login(
|
||||
@@ -51,13 +62,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
} on AppException catch (error) {
|
||||
// 429(AUTH_RATE_LIMITED)等文案经错误码映射;登录页的
|
||||
// AUTH_INVALID_CREDENTIALS 即「账号或密码错误」。
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.messageForError(error))),
|
||||
);
|
||||
if (mounted) setState(() => _errorText = l10n.messageForError(error));
|
||||
} on Object {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.errorGeneric)),
|
||||
);
|
||||
if (mounted) setState(() => _errorText = l10n.errorGeneric);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
@@ -72,7 +79,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.all(kSpace24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
@@ -83,16 +90,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
TextFormField(
|
||||
controller: _accountController,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.username,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.username),
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty)
|
||||
? l10n.fieldRequired
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: kSpace16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
@@ -100,15 +104,19 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
autocorrect: false,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
onFieldSubmitted: (_) => _submitting ? null : _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.password,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.password),
|
||||
validator: (value) => (value == null || value.isEmpty)
|
||||
? l10n.fieldRequired
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_errorText != null) ...[
|
||||
const SizedBox(height: kSpace16),
|
||||
StateBadge(
|
||||
variant: BadgeVariant.error,
|
||||
label: _errorText!,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: kSpace24),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
@@ -119,7 +127,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
)
|
||||
: Text(l10n.login),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: kSpace12),
|
||||
Text(
|
||||
l10n.loginHint,
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/// 修改密码页(UI-01 §4.5 主题统一,P3 计划 Task 3 基础;入口在设置页)。
|
||||
///
|
||||
/// 当前/新/确认三字段;客户端校验(新密码 ≥8 位、两次一致)先行,
|
||||
/// 服务端 [ApiClient.changePassword] 成功后 204——当前会话被服务端保留,
|
||||
/// 无需重登录:toast 提示后直接 pop 回设置页。失败以内联 StateBadge
|
||||
/// (图标 + 文案 + 错误色)展示;AUTH_INVALID_CREDENTIALS 在本页语义为
|
||||
/// 「当前密码错误」(覆写通用映射)。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -6,13 +15,10 @@ import '../core/app_exception.dart';
|
||||
import '../core/error_codes.dart';
|
||||
import '../core/error_l10n.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/state_badge.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 修改密码页(P3 计划 Task 3;入口在设置页)。
|
||||
///
|
||||
/// 当前/新/确认三字段;客户端校验(新密码 ≥8 位、两次一致)先行,
|
||||
/// 服务端 [ApiClient.changePassword] 成功后 204——当前会话被服务端保留,
|
||||
/// 无需重登录:toast 提示后直接 pop 回设置页。
|
||||
/// AUTH_INVALID_CREDENTIALS 在本页语义为「当前密码错误」(覆写通用映射)。
|
||||
/// 修改密码页。
|
||||
class PasswordChangePage extends ConsumerStatefulWidget {
|
||||
const PasswordChangePage({super.key});
|
||||
|
||||
@@ -30,6 +36,9 @@ class _PasswordChangePageState extends ConsumerState<PasswordChangePage> {
|
||||
bool _obscureAll = true;
|
||||
bool _submitting = false;
|
||||
|
||||
/// 最近一次提交失败文案(内联 StateBadge 展示;null = 无错误)。
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_oldController.dispose();
|
||||
@@ -40,7 +49,10 @@ class _PasswordChangePageState extends ConsumerState<PasswordChangePage> {
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _submitting = true);
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
@@ -55,19 +67,16 @@ class _PasswordChangePageState extends ConsumerState<PasswordChangePage> {
|
||||
);
|
||||
navigator.pop();
|
||||
} on AppException catch (error) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
error.code == ErrorCodes.authInvalidCredentials
|
||||
? l10n.errorOldPasswordIncorrect
|
||||
: l10n.messageForError(error),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _errorText =
|
||||
error.code == ErrorCodes.authInvalidCredentials
|
||||
? l10n.errorOldPasswordIncorrect
|
||||
: l10n.messageForError(error),
|
||||
);
|
||||
}
|
||||
} on Object {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.errorGeneric)),
|
||||
);
|
||||
if (mounted) setState(() => _errorText = l10n.errorGeneric);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
@@ -81,92 +90,98 @@ class _PasswordChangePageState extends ConsumerState<PasswordChangePage> {
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _oldController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.currentPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(kSpace24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _oldController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.currentPassword,
|
||||
),
|
||||
validator: (value) => (value == null || value.isEmpty)
|
||||
? l10n.fieldRequired
|
||||
: null,
|
||||
),
|
||||
validator: (value) => (value == null || value.isEmpty)
|
||||
? l10n.fieldRequired
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _newController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.newPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
const SizedBox(height: kSpace16),
|
||||
TextFormField(
|
||||
controller: _newController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.newPassword,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return l10n.fieldRequired;
|
||||
}
|
||||
if (value.length < 8) return l10n.passwordMinLength;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return l10n.fieldRequired;
|
||||
}
|
||||
if (value.length < 8) return l10n.passwordMinLength;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _confirmController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
onFieldSubmitted: (_) => _submitting ? null : _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.confirmNewPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
const SizedBox(height: kSpace16),
|
||||
TextFormField(
|
||||
controller: _confirmController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
onFieldSubmitted: (_) => _submitting ? null : _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.confirmNewPassword,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return l10n.fieldRequired;
|
||||
}
|
||||
if (value != _newController.text) {
|
||||
return l10n.passwordMismatch;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return l10n.fieldRequired;
|
||||
}
|
||||
if (value != _newController.text) {
|
||||
return l10n.passwordMismatch;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => _obscureAll = !_obscureAll),
|
||||
icon: Icon(
|
||||
_obscureAll
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
const SizedBox(height: kSpace8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => _obscureAll = !_obscureAll),
|
||||
icon: Icon(
|
||||
_obscureAll
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(l10n.passwordChangeTitle),
|
||||
),
|
||||
],
|
||||
if (_errorText != null) ...[
|
||||
const SizedBox(height: kSpace8),
|
||||
StateBadge(
|
||||
variant: BadgeVariant.error,
|
||||
label: _errorText!,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: kSpace16),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(l10n.passwordChangeTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/// 上传队列页(P3 计划 Task 9,需求 4.3 / R17 / O01)。
|
||||
/// 上传中心(UI-01 §4.4,P3 计划 Task 9 基础上重构)。
|
||||
///
|
||||
/// 顶部摘要卡(筛选范围标注「相册·时间·本设备」+ 四组任务数:已完成/
|
||||
/// 上传中/等待中/需关注——按任务数统计,单任务百分比才走字节进度)→
|
||||
/// 筛选栏(相册下拉 × 时间快捷 × 状态分组 chips:全部/进行中/需关注/
|
||||
/// 已完成)→ 任务行 = 缩略图 + 文件名 + 状态徽章(StateBadge 图标+文案+
|
||||
/// 颜色三重表达)+ 状态专属文案/操作(§4.4 表格:确认中不显示已公开、
|
||||
/// 准备失败走重新选择而非普通重试、终态走「移除记录」并明确不删云端
|
||||
/// 照片)+ 传输进度(进度总线行级订阅)。存在可重试失败时底部
|
||||
/// 「一键重试」条(仅作用于可重试任务,文案说明范围)。
|
||||
///
|
||||
/// 三维筛选(相册下拉 × 状态 chips × 时间快捷)→ [QueueFilter];任务行
|
||||
/// = 缩略图(受管副本,缺失/已清理显示占位)+ 文件名 + 相册名 + 状态
|
||||
/// 徽章 + 尝试/错误(本地化错误码)+ 传输进度(进度总线行级订阅)+ 按
|
||||
/// 状态动作(重试/取消/删除);失败任务存在时底部「一键重试全部失败」。
|
||||
/// 列表经 TaskStore 写通知 + 200ms 防抖实时刷新(连拍风暴不逐条重建)。
|
||||
library;
|
||||
|
||||
@@ -23,6 +28,10 @@ import '../features/uploads/queue_l10n.dart';
|
||||
import '../features/uploads/task_state.dart';
|
||||
import '../features/uploads/task_store.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/empty_state.dart';
|
||||
import '../ui/components/error_state.dart';
|
||||
import '../ui/components/state_badge.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 队列 Tab(主壳 IndexedStack 内常驻)。
|
||||
class QueueTab extends ConsumerWidget {
|
||||
@@ -32,9 +41,9 @@ class QueueTab extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final counts = ref.watch(taskCountsProvider);
|
||||
final autoRetryable =
|
||||
(counts[UploadTaskState.retryableFailed] ?? 0) +
|
||||
(counts[UploadTaskState.waitingNetwork] ?? 0);
|
||||
// 批量重试仅针对可重试失败(等待网络自动恢复,不混入;准备失败需
|
||||
// 重新选图,不能普通重试冒充恢复——§4.4)。
|
||||
final retryable = counts[UploadTaskState.retryableFailed] ?? 0;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.queue),
|
||||
@@ -48,18 +57,137 @@ class QueueTab extends ConsumerWidget {
|
||||
),
|
||||
body: const Column(
|
||||
children: [
|
||||
_SummaryCard(),
|
||||
_FilterBar(),
|
||||
Expanded(child: _TaskListView()),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: autoRetryable > 0 ? const _BatchRetryBar() : null,
|
||||
bottomNavigationBar: retryable > 0 ? const _BatchRetryBar() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 摘要卡 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 顶部摘要:筛选范围标注 + 四组任务数(SQL 聚合,与列表同口径)。
|
||||
class _SummaryCard extends ConsumerWidget {
|
||||
const _SummaryCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final filter = ref.watch(queueFilterProvider);
|
||||
final albums = ref.watch(albumListProvider).items;
|
||||
final counts = ref.watch(queueSummaryProvider);
|
||||
|
||||
// 范围标注:[相册名·]时间范围·本设备(本机任务库只含本设备任务,
|
||||
// 跨设备记录在服务端历史页——两者不合并统计)。
|
||||
final album = filter.albumId == null
|
||||
? null
|
||||
: albums.where((a) => a.id == filter.albumId).firstOrNull;
|
||||
final scope = [
|
||||
if (album != null) albumDisplayTitle(album, l10n.localeName),
|
||||
_FilterBar._timeLabel(l10n, filter.time),
|
||||
l10n.thisDevice,
|
||||
].join(' · ');
|
||||
|
||||
final light = theme.brightness == Brightness.light;
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(kSpace16, kSpace12, kSpace16, 0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(kSpace16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.summarize_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: kSpace8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
scope,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: kSpace8),
|
||||
Wrap(
|
||||
spacing: kSpace16,
|
||||
runSpacing: kSpace4,
|
||||
children: [
|
||||
_SummaryCount(
|
||||
value: completedSummaryOf(counts),
|
||||
label: l10n.queueSummaryCompleted,
|
||||
color: light ? kColorSuccess : kColorSuccessDark,
|
||||
),
|
||||
_SummaryCount(
|
||||
value: uploadingSummaryOf(counts),
|
||||
label: l10n.queueSummaryUploading,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
_SummaryCount(
|
||||
value: waitingSummaryOf(counts),
|
||||
label: l10n.queueSummaryWaiting,
|
||||
color: light ? kColorWaiting : kColorWaitingDark,
|
||||
),
|
||||
_SummaryCount(
|
||||
value: failedCountOf(counts),
|
||||
label: l10n.queueSummaryNeedAttention,
|
||||
color: light ? kColorError : kColorErrorDark,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 摘要单项:数字(主文字色、表格数字)+ 标签(语义色)。
|
||||
class _SummaryCount extends StatelessWidget {
|
||||
const _SummaryCount({
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final int value;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$value',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kSpace4),
|
||||
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: color)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 筛选栏 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(需求 4.3 三维)。
|
||||
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(§4.4 四组)。
|
||||
class _FilterBar extends ConsumerWidget {
|
||||
const _FilterBar();
|
||||
|
||||
@@ -136,11 +264,8 @@ class _FilterBar extends ConsumerWidget {
|
||||
static String _stateLabel(AppLocalizations l10n, QueueStateFilter state) =>
|
||||
switch (state) {
|
||||
QueueStateFilter.all => l10n.queueStateAll,
|
||||
QueueStateFilter.preparing => l10n.queueStatePreparing,
|
||||
QueueStateFilter.queued => l10n.queueStateQueued,
|
||||
QueueStateFilter.uploading => l10n.queueStateUploading,
|
||||
QueueStateFilter.waitingNetwork => l10n.queueStateWaitingNetwork,
|
||||
QueueStateFilter.failed => l10n.queueStateFailed,
|
||||
QueueStateFilter.inProgress => l10n.queueStateInProgress,
|
||||
QueueStateFilter.needsAttention => l10n.queueStateNeedsAttention,
|
||||
QueueStateFilter.completed => l10n.queueStateCompleted,
|
||||
};
|
||||
|
||||
@@ -208,22 +333,16 @@ class _TaskListView extends ConsumerWidget {
|
||||
return tasksAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.errorGeneric),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(queueTasksProvider),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
error: (error, _) => ErrorState(
|
||||
message: l10n.errorGeneric,
|
||||
onRetry: () => ref.invalidate(queueTasksProvider),
|
||||
),
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
return Center(child: Text(l10n.queueEmpty));
|
||||
return EmptyState(
|
||||
icon: Icons.cloud_upload_outlined,
|
||||
title: l10n.queueEmpty,
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
@@ -235,7 +354,7 @@ class _TaskListView extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条任务行。
|
||||
/// 单条任务行(§4.4 状态表逐态落地)。
|
||||
class _TaskTile extends ConsumerWidget {
|
||||
const _TaskTile({required this.task});
|
||||
|
||||
@@ -255,24 +374,32 @@ class _TaskTile extends ConsumerWidget {
|
||||
: task.albumId;
|
||||
final failedLike =
|
||||
task.state == UploadTaskState.preparationFailed ||
|
||||
task.state == UploadTaskState.retryableFailed ||
|
||||
task.state == UploadTaskState.waitingNetwork;
|
||||
task.state == UploadTaskState.retryableFailed;
|
||||
|
||||
return ListTile(
|
||||
leading: _Thumb(
|
||||
path: task.cleanedAt == null ? task.sourceFilePath : null,
|
||||
return Padding(
|
||||
// 行键(task.id)供 widget 测试行级锚定(状态徽章/动作图标定位)。
|
||||
key: ValueKey('queue-task-${task.id}'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace8, kSpace16, kSpace8,
|
||||
),
|
||||
title: Text(task.filename, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
_StateChip(state: task.state),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_Thumb(path: task.cleanedAt == null ? task.sourceFilePath : null),
|
||||
const SizedBox(width: kSpace12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
task.filename,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
albumName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -280,34 +407,92 @@ class _TaskTile extends ConsumerWidget {
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: kSpace4),
|
||||
Row(
|
||||
children: [
|
||||
// 状态徽章即 §4.4「界面含义」列的唯一文案表达(图标 +
|
||||
// 颜色 + 文字三重编码);尝试次数(>0)为补充说明。
|
||||
StateBadge(
|
||||
compact: true,
|
||||
variant: _badgeVariant(task.state),
|
||||
label: _badgeLabel(l10n, task.state),
|
||||
),
|
||||
if (task.attempt > 0) ...[
|
||||
const SizedBox(width: kSpace8),
|
||||
Text(
|
||||
l10n.queueAttemptCount(task.attempt),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (task.state == UploadTaskState.waitingNetwork) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.queueWaitingNetworkHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.brightness == Brightness.light
|
||||
? kColorWaiting
|
||||
: kColorWaitingDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (failedLike && task.lastError != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.messageForTaskError(task.lastError!),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (task.state == UploadTaskState.uploading ||
|
||||
task.state == UploadTaskState.uploadedConfirming)
|
||||
_ProgressLine(
|
||||
taskId: task.id,
|
||||
confirming: task.state ==
|
||||
UploadTaskState.uploadedConfirming,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (failedLike && task.lastError != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
task.attempt > 0
|
||||
? '${l10n.messageForTaskError(task.lastError!)} · '
|
||||
'${l10n.queueAttemptCount(task.attempt)}'
|
||||
: l10n.messageForTaskError(task.lastError!),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (task.state == UploadTaskState.uploading ||
|
||||
task.state == UploadTaskState.uploadedConfirming)
|
||||
_ProgressLine(
|
||||
taskId: task.id,
|
||||
confirming: task.state == UploadTaskState.uploadedConfirming,
|
||||
),
|
||||
_TaskActions(task: task),
|
||||
],
|
||||
),
|
||||
trailing: _TaskActions(task: task),
|
||||
);
|
||||
}
|
||||
|
||||
/// 徽章语义变体(§4.4:进行=主色、等待=琥珀、失败=红、完成=绿、取消=灰)。
|
||||
static BadgeVariant _badgeVariant(UploadTaskState state) =>
|
||||
switch (state) {
|
||||
UploadTaskState.preparing ||
|
||||
UploadTaskState.uploading ||
|
||||
UploadTaskState.uploadedConfirming => BadgeVariant.info,
|
||||
UploadTaskState.queued ||
|
||||
UploadTaskState.waitingNetwork => BadgeVariant.warning,
|
||||
UploadTaskState.preparationFailed ||
|
||||
UploadTaskState.retryableFailed => BadgeVariant.error,
|
||||
UploadTaskState.completed => BadgeVariant.success,
|
||||
UploadTaskState.cancelled => BadgeVariant.neutral,
|
||||
};
|
||||
|
||||
/// 徽章文案 = §4.4 界面含义列(确认中不显示「已公开」等超集语义)。
|
||||
static String _badgeLabel(AppLocalizations l10n, UploadTaskState state) =>
|
||||
switch (state) {
|
||||
UploadTaskState.preparing => l10n.queueRowPreparing,
|
||||
UploadTaskState.preparationFailed => l10n.queueRowFileUnavailable,
|
||||
UploadTaskState.queued => l10n.queueRowWaitingUpload,
|
||||
UploadTaskState.uploading => l10n.taskStateUploading,
|
||||
UploadTaskState.uploadedConfirming => l10n.queueRowConfirming,
|
||||
UploadTaskState.completed => l10n.taskStateCompleted,
|
||||
UploadTaskState.waitingNetwork => l10n.taskStateWaitingNetwork,
|
||||
UploadTaskState.retryableFailed => l10n.taskStateRetryableFailed,
|
||||
UploadTaskState.cancelled => l10n.taskStateCancelled,
|
||||
};
|
||||
}
|
||||
|
||||
/// 缩略图:受管副本存在则显示,缺失/已清理/解码失败显示占位图标。
|
||||
@@ -324,7 +509,7 @@ class _Thumb extends StatelessWidget {
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(kRadiusBadge),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.image_outlined,
|
||||
@@ -334,7 +519,7 @@ class _Thumb extends StatelessWidget {
|
||||
);
|
||||
if (path == null) return placeholder();
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(kRadiusBadge),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
@@ -348,62 +533,8 @@ class _Thumb extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态徽章(分组配色:失败红/完成绿/进行中主色/等待橙/其余中性)。
|
||||
class _StateChip extends StatelessWidget {
|
||||
const _StateChip({required this.state});
|
||||
|
||||
final UploadTaskState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final (color, onColor) = switch (state) {
|
||||
UploadTaskState.preparing => (
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
UploadTaskState.preparationFailed || UploadTaskState.retryableFailed => (
|
||||
theme.colorScheme.errorContainer,
|
||||
theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
UploadTaskState.queued => (
|
||||
theme.colorScheme.secondaryContainer,
|
||||
theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
UploadTaskState.uploading || UploadTaskState.uploadedConfirming => (
|
||||
theme.colorScheme.primaryContainer,
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
UploadTaskState.completed => (
|
||||
theme.colorScheme.tertiaryContainer,
|
||||
theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
UploadTaskState.waitingNetwork => (
|
||||
theme.colorScheme.tertiaryContainer,
|
||||
theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
UploadTaskState.cancelled => (
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
l10n.labelForTaskState(state),
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: onColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 传输进度行:上传中 = 进度总线行级订阅(分数进度条);确认中 = 不确定
|
||||
/// 进度条(等待服务端核验)。
|
||||
/// 传输进度行:上传中 = 进度总线行级订阅(分数进度条 + 百分比文案);
|
||||
/// 确认中 = 不确定进度条(等待服务端核验,不伪造百分比)。
|
||||
class _ProgressLine extends ConsumerWidget {
|
||||
const _ProgressLine({required this.taskId, required this.confirming});
|
||||
|
||||
@@ -412,6 +543,7 @@ class _ProgressLine extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6, right: 8),
|
||||
child: StreamBuilder<UploadTaskProgress>(
|
||||
@@ -421,9 +553,22 @@ class _ProgressLine extends ConsumerWidget {
|
||||
.where((p) => p.taskId == taskId),
|
||||
builder: (context, snapshot) {
|
||||
final fraction = snapshot.data?.fraction;
|
||||
return LinearProgressIndicator(
|
||||
value: confirming ? null : fraction,
|
||||
minHeight: 4,
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: confirming ? null : fraction,
|
||||
minHeight: 4,
|
||||
),
|
||||
),
|
||||
if (!confirming && fraction != null) ...[
|
||||
const SizedBox(width: kSpace8),
|
||||
Text(
|
||||
l10n.queueRowUploadingPercent((fraction * 100).round()),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -442,16 +587,25 @@ class _TaskActions extends ConsumerWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final account = ref.watch(sessionAccountProvider).value;
|
||||
switch (task.state) {
|
||||
// 准备失败重试 = 重新选图(原始源路径不落库,见准备器注释)——
|
||||
// 不能普通重试冒充恢复(§4.4)。
|
||||
case UploadTaskState.preparationFailed:
|
||||
// 准备失败重试 = 重新选图(原始源路径不落库,见准备器注释)。
|
||||
return IconButton(
|
||||
tooltip: l10n.retry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
return _RowAction(
|
||||
icon: Icons.image_search_outlined,
|
||||
label: l10n.queueActionSelectAgain,
|
||||
onPressed: account == null
|
||||
? null
|
||||
: () => _retryPreparation(context, ref, account),
|
||||
);
|
||||
case UploadTaskState.retryableFailed:
|
||||
return _RowAction(
|
||||
icon: Icons.refresh,
|
||||
label: l10n.retry,
|
||||
onPressed: account == null
|
||||
? null
|
||||
: () => ref.read(uploadEngineProvider).retryNow(account, task.id),
|
||||
);
|
||||
// 网络等待自动恢复,保留手动重试入口(§4.4「保留已有重试」)。
|
||||
case UploadTaskState.waitingNetwork:
|
||||
return IconButton(
|
||||
tooltip: l10n.retry,
|
||||
@@ -469,12 +623,16 @@ class _TaskActions extends ConsumerWidget {
|
||||
? null
|
||||
: () => ref.read(uploadEngineProvider).cancel(account, task.id),
|
||||
);
|
||||
// 终态:移除本机记录(不删云端照片,确认弹窗明确区分)。
|
||||
case UploadTaskState.completed:
|
||||
case UploadTaskState.cancelled:
|
||||
return IconButton(
|
||||
tooltip: l10n.delete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: account == null ? null : () => _delete(ref, account),
|
||||
return _RowAction(
|
||||
icon: Icons.delete_outline,
|
||||
label: l10n.queueActionRemoveRecord,
|
||||
foreground: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
onPressed: account == null
|
||||
? null
|
||||
: () => _confirmRemoveRecord(context, ref, account),
|
||||
);
|
||||
case UploadTaskState.uploading:
|
||||
case UploadTaskState.uploadedConfirming:
|
||||
@@ -501,8 +659,32 @@ class _TaskActions extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 终态删除:删行 + 尽力删受管副本(行删除后清理执行器不再可见该文件)。
|
||||
Future<void> _delete(WidgetRef ref, String account) async {
|
||||
/// 移除记录确认:明确仅清本机记录、不删 OSS 照片(§4.4)。
|
||||
Future<void> _confirmRemoveRecord(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String account,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.queueRemoveRecordTitle),
|
||||
content: Text(l10n.queueRemoveRecordBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.queueRemoveRecordConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
// 删行 + 尽力删受管副本(行删除后清理执行器不再可见该文件)。
|
||||
final store = ref.read(taskStoreProvider);
|
||||
await store.delete(task.id);
|
||||
if (task.cleanedAt == null) {
|
||||
@@ -518,7 +700,38 @@ class _TaskActions extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部批量重试条(存在可自动重试的失败任务时出现)。
|
||||
/// 行内文字动作(重新选择/重试/移除记录):紧凑档,弱化为次级视觉。
|
||||
class _RowAction extends StatelessWidget {
|
||||
const _RowAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.foreground,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final Color? foreground;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton.icon(
|
||||
onPressed: onPressed,
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: kSpace8),
|
||||
minimumSize: const Size(48, 36),
|
||||
foregroundColor:
|
||||
foreground ?? Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
icon: Icon(icon, size: 16),
|
||||
label: Text(label),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部批量重试条(存在可重试失败任务时出现;仅作用于 retryableFailed)。
|
||||
class _BatchRetryBar extends ConsumerWidget {
|
||||
const _BatchRetryBar();
|
||||
|
||||
@@ -527,7 +740,9 @@ class _BatchRetryBar extends ConsumerWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final counts = ref.watch(taskCountsProvider);
|
||||
final failed = failedCountOf(counts);
|
||||
// 标题计数与摘要卡「需关注」同口径(准备失败 + 上传失败);按钮仅
|
||||
// 重试其中的可重试子集,范围文案明确区分(出现条件见 QueueTab)。
|
||||
final needsAttention = failedCountOf(counts);
|
||||
return Material(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
child: SafeArea(
|
||||
@@ -541,11 +756,26 @@ class _BatchRetryBar extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${l10n.queueStateFailed} · $failed',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
child: Column(
|
||||
// Scaffold 对 bottomNavigationBar 给的是宽松高度约束,
|
||||
// 默认 max 会把整条栏撑到剩余全高、把 body 压成 0;min 使
|
||||
// 栏高 = 两行文字 + 内边距。
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${l10n.queueStateNeedsAttention} · $needsAttention',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.queueRetryAllScope,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
@@ -564,21 +794,16 @@ class _BatchRetryBar extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 全部可自动重试任务(retryableFailed + waitingNetwork,跨当前筛选)
|
||||
/// 立即重新入队。
|
||||
/// 全部可重试失败任务(retryableFailed,跨当前筛选)立即重新入队。
|
||||
/// 等待网络任务自动恢复、准备失败需换源重选,均不进入批量重试。
|
||||
Future<void> _retryAll(WidgetRef ref) async {
|
||||
final account = ref.read(sessionAccountProvider).value;
|
||||
if (account == null) return;
|
||||
final engine = ref.read(uploadEngineProvider);
|
||||
final tasks = await ref
|
||||
.read(taskStoreProvider)
|
||||
.listByAccount(
|
||||
account,
|
||||
states: const {
|
||||
UploadTaskState.retryableFailed,
|
||||
UploadTaskState.waitingNetwork,
|
||||
},
|
||||
);
|
||||
final tasks = await ref.read(taskStoreProvider).listByAccount(
|
||||
account,
|
||||
states: const {UploadTaskState.retryableFailed},
|
||||
);
|
||||
for (final task in tasks) {
|
||||
await engine.retryNow(account, task.id);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/// 服务端上传历史页(P3 计划 Task 6,A19 语义)。
|
||||
/// 服务端上传历史页(UI-01 §4.4 主题统一,P3 计划 Task 6 / A19 语义)。
|
||||
///
|
||||
/// GET /uploads(operationId listUploads)游标分页展示**当前账号跨设备**
|
||||
/// 的上传会话记录:会话状态 / photo.processingState / sourceDeviceId /
|
||||
/// 创建时间。顶部横幅明确标注「本机待传队列不在此列」——本机队列是
|
||||
/// drift 任务库(TaskStore),两者数据源与生命周期不同(A19 标注要求)。
|
||||
/// 创建时间。页面标题即「服务端历史(跨设备)」,顶部说明条明确「本机
|
||||
/// 待传队列在上传页」——本机队列是 drift 任务库(TaskStore),两者数据源
|
||||
/// 与生命周期不同,不合并统计(A19 标注要求)。
|
||||
///
|
||||
/// 记录不含 filename(契约 UploadSessionRecord 无该字段),行首展示
|
||||
/// clientTaskId 前 8 位作识别;sourceDeviceId 与本机 [DeviceId] 一致时
|
||||
@@ -23,6 +24,10 @@ import '../core/error_l10n.dart';
|
||||
import '../features/photos/models.dart';
|
||||
import '../features/uploads/models.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/empty_state.dart';
|
||||
import '../ui/components/error_state.dart';
|
||||
import '../ui/components/state_badge.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 服务端上传历史(从队列页占位入口进入)。
|
||||
class ServerHistoryPage extends ConsumerStatefulWidget {
|
||||
@@ -139,11 +144,11 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// A19 标注横幅:与「队列」页(本机任务库)的数据源区分。
|
||||
// A19 标注说明条:与「上传」页(本机任务库)的数据源区分。
|
||||
Card(
|
||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
margin: const EdgeInsets.fromLTRB(kSpace16, kSpace12, kSpace16, 0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(kSpace12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -151,7 +156,7 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: kSpace8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.serverHistoryBanner,
|
||||
@@ -167,61 +172,44 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
: _errorCode != null
|
||||
? ErrorState(
|
||||
message: l10n.messageForErrorCode(_errorCode!),
|
||||
onRetry: _reload,
|
||||
)
|
||||
: 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.serverHistoryEmpty,
|
||||
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,
|
||||
: _items.isEmpty
|
||||
? EmptyState(
|
||||
icon: Icons.cloud_done_outlined,
|
||||
title: l10n.serverHistoryEmpty,
|
||||
)
|
||||
: 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(),
|
||||
);
|
||||
}
|
||||
return _sessionTile(l10n, theme, _items[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (index >= _items.length - 3 &&
|
||||
_hasMore &&
|
||||
!_loadingMore) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _loadMore(),
|
||||
);
|
||||
}
|
||||
return _sessionTile(l10n, theme, _items[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -260,14 +248,26 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_HistoryTag(
|
||||
StateBadge(
|
||||
compact: true,
|
||||
variant: switch (record.state) {
|
||||
UploadSessionState.confirmed => BadgeVariant.success,
|
||||
UploadSessionState.pending => BadgeVariant.info,
|
||||
UploadSessionState.cancelled ||
|
||||
UploadSessionState.expired => BadgeVariant.neutral,
|
||||
},
|
||||
label: _sessionStateLabel(l10n, record.state),
|
||||
highlight: record.state == UploadSessionState.confirmed,
|
||||
),
|
||||
_HistoryTag(
|
||||
StateBadge(
|
||||
compact: true,
|
||||
variant: switch (record.photo.processingState) {
|
||||
PhotoProcessingState.ready => BadgeVariant.success,
|
||||
PhotoProcessingState.processing ||
|
||||
PhotoProcessingState.uploaded => BadgeVariant.info,
|
||||
PhotoProcessingState.pendingUpload => BadgeVariant.warning,
|
||||
PhotoProcessingState.processingFailed => BadgeVariant.error,
|
||||
},
|
||||
label: _processingLabel(l10n, record.photo.processingState),
|
||||
highlight: record.photo.processingState ==
|
||||
PhotoProcessingState.ready,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -308,33 +308,3 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话记录标签(会话状态/处理状态)。
|
||||
class _HistoryTag extends StatelessWidget {
|
||||
const _HistoryTag({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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
/// 分享二维码对话框(P3 计划 Task 4)。
|
||||
/// 分享二维码对话框(UI-01 §4.5 分层重构,P3 计划 Task 4 基础)。
|
||||
///
|
||||
/// viewer 链接构造:`{VIEWER_BASE}/a/{publicToken}`([ApiConfig.
|
||||
/// viewerAlbumUrl],VIEWER_BASE 经 --dart-define 覆盖,dev=模拟器宿主
|
||||
/// 回环 5175)。QrImageView 渲染链接 + 链接文本可长按选中 + 一键复制。
|
||||
/// 分层:相册标题 → 二维码(白底突出,暗色主题保持扫码对比度)→
|
||||
/// 链接文本(可选中)→ 操作行(复制链接为主操作,「在浏览器中打开」为
|
||||
/// 次操作,经 url_launcher 外链打开)。viewer 链接构造:
|
||||
/// `{VIEWER_BASE}/a/{publicToken}`([ApiConfig.viewerAlbumUrl])。
|
||||
///
|
||||
/// 用 [Dialog] 而非 [AlertDialog]:AlertDialog 内容经 IntrinsicWidth
|
||||
/// 测量,而 QrImageView 内部使用 LayoutBuilder(不支持 intrinsic 计算)
|
||||
/// 会抛布局断言,普通 Dialog 无此路径。
|
||||
/// 会抛布局断言,普通 Dialog 无此路径。外链打开经 Provider 注入
|
||||
/// ([urlLauncherProvider]),widget 测试 override 替身免平台通道。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../core/config.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 观众分享二维码对话框([publicToken] 来自相册详情)。
|
||||
class ShareQrDialog extends StatelessWidget {
|
||||
const ShareQrDialog({required this.publicToken, super.key});
|
||||
/// 外链打开注入点(生产=url_launcher;测试 override)。
|
||||
typedef UrlLauncher = Future<bool> Function(String url);
|
||||
|
||||
/// Provider:外部浏览器打开 URL(LaunchMode.externalApplication——浏览器
|
||||
/// 才能承载观众页的全部交互,内置 WebView 不适用)。
|
||||
final urlLauncherProvider = Provider<UrlLauncher>(
|
||||
(ref) => (url) =>
|
||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
|
||||
);
|
||||
|
||||
/// 观众分享二维码对话框([publicToken] 来自相册详情;[albumTitle] 可选
|
||||
/// 分层标题,缺省回退通用标题)。
|
||||
class ShareQrDialog extends ConsumerWidget {
|
||||
const ShareQrDialog({required this.publicToken, super.key, this.albumTitle});
|
||||
|
||||
final String publicToken;
|
||||
|
||||
/// 相册展示名(当前界面语言回退解析后的标题)。
|
||||
final String? albumTitle;
|
||||
|
||||
Future<void> _copy(BuildContext context, String url) async {
|
||||
await Clipboard.setData(ClipboardData(text: url));
|
||||
if (!context.mounted) return;
|
||||
@@ -32,8 +51,18 @@ class ShareQrDialog extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openInBrowser(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String url,
|
||||
) async {
|
||||
final launched = await ref.read(urlLauncherProvider)(url);
|
||||
if (!launched || !context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final url = ApiConfig.viewerAlbumUrl(publicToken);
|
||||
@@ -44,18 +73,26 @@ class ShareQrDialog extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 分层 1:相册标题(回退通用标题)。
|
||||
Text(
|
||||
l10n.shareQrTitle,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
albumTitle ?? l10n.shareQrTitle,
|
||||
style: theme.textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: kSpace4),
|
||||
Text(
|
||||
l10n.shareQrTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace16),
|
||||
// 分层 2:二维码(白底突出)。
|
||||
Center(
|
||||
// 白底保证暗色主题下的扫码对比度。
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.all(kSpace8),
|
||||
child: QrImageView(
|
||||
data: url,
|
||||
version: QrVersions.auto,
|
||||
@@ -64,13 +101,14 @@ class ShareQrDialog extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: kSpace12),
|
||||
// 分层 3:链接文本(可长按选中)。
|
||||
SelectableText(
|
||||
url,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: kSpace8),
|
||||
Text(
|
||||
l10n.qrHint,
|
||||
textAlign: TextAlign.center,
|
||||
@@ -78,18 +116,21 @@ class ShareQrDialog extends StatelessWidget {
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: kSpace12),
|
||||
// 分层 4:操作行——复制为主操作,浏览器打开为次操作。
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _copy(context, url),
|
||||
icon: const Icon(Icons.copy_outlined),
|
||||
label: Text(l10n.copyLink),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _copy(context, url),
|
||||
icon: const Icon(Icons.copy_outlined, size: 18),
|
||||
label: Text(l10n.copyLink),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kSpace8),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.ok),
|
||||
onPressed: () => _openInBrowser(context, ref, url),
|
||||
child: Text(l10n.openInBrowser),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1033,6 +1033,70 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.3.33"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.4.2"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.3"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.6"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -35,6 +35,7 @@ dependencies:
|
||||
# l10n(与 flutter_localizations 来自 SDK 的约束保持一致)
|
||||
intl: 0.20.2
|
||||
timezone: ^0.11.1
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -95,25 +95,31 @@ void main() {
|
||||
return task;
|
||||
}
|
||||
|
||||
group('QueueStateFilter.states 分组', () {
|
||||
group('QueueStateFilter.states 分组(UI-01 §4.4 四组 chips)', () {
|
||||
test('all → 空集(不过滤)', () {
|
||||
expect(QueueStateFilter.all.states, isEmpty);
|
||||
});
|
||||
|
||||
test('failed 组 = 准备失败 + 上传失败(不含等待网络)', () {
|
||||
expect(QueueStateFilter.failed.states, {
|
||||
test('needsAttention 组 = 准备失败 + 上传失败(不含等待网络)', () {
|
||||
expect(QueueStateFilter.needsAttention.states, {
|
||||
UploadTaskState.preparationFailed,
|
||||
UploadTaskState.retryableFailed,
|
||||
});
|
||||
});
|
||||
|
||||
test('uploading 组 = 上传中 + 确认中;completed 组不含 cancelled', () {
|
||||
expect(QueueStateFilter.uploading.states, {
|
||||
test('inProgress 组 = 准备/排队/上传/确认 + 等待网络(自动恢复归活任务)',
|
||||
() {
|
||||
expect(QueueStateFilter.inProgress.states, {
|
||||
UploadTaskState.preparing,
|
||||
UploadTaskState.queued,
|
||||
UploadTaskState.uploading,
|
||||
UploadTaskState.uploadedConfirming,
|
||||
UploadTaskState.waitingNetwork,
|
||||
});
|
||||
expect(QueueStateFilter.completed.states,
|
||||
{UploadTaskState.completed});
|
||||
});
|
||||
|
||||
test('completed 组 = 仅 completed(cancelled 只在「全部」可见)', () {
|
||||
expect(QueueStateFilter.completed.states, {UploadTaskState.completed});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,7 +158,7 @@ void main() {
|
||||
expect(filter.matches(other, now: now), isFalse);
|
||||
});
|
||||
|
||||
test('状态维度:failed 组命中组内两态、排除组外', () async {
|
||||
test('状态维度:needsAttention 组命中组内两态、排除组外', () async {
|
||||
final prep = await seedTask(
|
||||
state: UploadTaskState.preparationFailed,
|
||||
createdAt: now,
|
||||
@@ -166,7 +172,7 @@ void main() {
|
||||
createdAt: now,
|
||||
);
|
||||
final filter = const QueueFilter(
|
||||
state: QueueStateFilter.failed,
|
||||
state: QueueStateFilter.needsAttention,
|
||||
);
|
||||
|
||||
expect(filter.matches(prep, now: now), isTrue);
|
||||
@@ -225,7 +231,7 @@ void main() {
|
||||
);
|
||||
const filter = QueueFilter(
|
||||
albumId: 'alb-1',
|
||||
state: QueueStateFilter.failed,
|
||||
state: QueueStateFilter.needsAttention,
|
||||
time: QueueTimeFilter.last30Days,
|
||||
);
|
||||
|
||||
@@ -261,5 +267,26 @@ void main() {
|
||||
expect(pendingCountOf(counts), 15);
|
||||
expect(failedCountOf(counts), 13);
|
||||
});
|
||||
|
||||
test('摘要行计数(UI-01 §4.4:按任务数,不与字节进度混口径)', () {
|
||||
final counts = <UploadTaskState, int>{
|
||||
for (final s in UploadTaskState.values) s: 0,
|
||||
}
|
||||
..[UploadTaskState.preparing] = 1
|
||||
..[UploadTaskState.queued] = 2
|
||||
..[UploadTaskState.uploading] = 3
|
||||
..[UploadTaskState.uploadedConfirming] = 4
|
||||
..[UploadTaskState.waitingNetwork] = 5
|
||||
..[UploadTaskState.preparationFailed] = 6
|
||||
..[UploadTaskState.retryableFailed] = 7
|
||||
..[UploadTaskState.completed] = 8
|
||||
..[UploadTaskState.cancelled] = 9;
|
||||
// 已完成 = 终态 completed(不含取消)。
|
||||
expect(completedSummaryOf(counts), 8);
|
||||
// 上传中 = 传输 + 确认。
|
||||
expect(uploadingSummaryOf(counts), 7);
|
||||
// 等待中 = 准备 + 排队 + 等待网络。
|
||||
expect(waitingSummaryOf(counts), 8);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +78,34 @@ void main() {
|
||||
await tester.tap(finder);
|
||||
}
|
||||
|
||||
testWidgets('三分组结构(UI-01 §4.5):活动信息/多语言内容/访问设置 + 双语 tab',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
await pumpForm(tester, clientOf(adapter));
|
||||
|
||||
// 三个分组标题(SectionHeader)。
|
||||
expect(find.text('Event info'), findsOneWidget);
|
||||
expect(find.text('Multilingual content'), findsOneWidget);
|
||||
expect(find.text('Access settings'), findsOneWidget);
|
||||
|
||||
// 多语言内容:zh-CN/en 双语 tab(当前 zh tab → 标题已填默认语言星标)。
|
||||
// 「中文/English」同时出现在内容 tab 与默认语言分段选择中,需锚定 TabBar。
|
||||
final tabBar = find.byType(TabBar);
|
||||
expect(find.descendant(of: tabBar, matching: find.text('中文')), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: tabBar, matching: find.text('English')), findsOneWidget);
|
||||
expect(find.text('Title *'), findsOneWidget, reason: '默认语言标题必填星标');
|
||||
|
||||
// 切到 en tab → 英文字段(非默认语言,无星标)。
|
||||
await tester.tap(find.descendant(of: tabBar, matching: find.text('English')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Title'), findsOneWidget);
|
||||
expect(find.text('Title *'), findsNothing);
|
||||
|
||||
// 访问设置:默认语言分段选择(两项)。
|
||||
expect(find.byType(SegmentedButton<String>), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('创建:空表单提交 → 汇总错误(默认语言标题/开始时间),不发请求',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
@@ -193,9 +221,10 @@ void main() {
|
||||
|
||||
// 编辑模式四开关。
|
||||
expect(find.byType(SwitchListTile), findsNWidgets(4));
|
||||
// 字段预填(当前 zh tab;title 为第一个文本字段)。
|
||||
// 字段预填(当前 zh tab;UI-01 分组后 TextFormField 顺序 = 时区 →
|
||||
// 标题(zh tab),标题取第 2 个)。
|
||||
final titleField = tester.widget<TextFormField>(
|
||||
find.byType(TextFormField).first,
|
||||
find.byType(TextFormField).at(1),
|
||||
);
|
||||
expect(titleField.controller!.text, '开幕晚宴');
|
||||
// 开关初值与原相册一致。
|
||||
@@ -291,7 +320,7 @@ void main() {
|
||||
await pumpForm(tester, clientOf(adapter), existing: existing);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextFormField).first,
|
||||
find.byType(TextFormField).at(1),
|
||||
'闭幕演出',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
@@ -191,7 +191,7 @@ void main() {
|
||||
testWidgets('快门即拍即入队:任务落库 queued + 待传徽章 1', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
|
||||
expect(find.text('Pending 0'), findsOneWidget);
|
||||
expect(find.text('Queued 0'), findsOneWidget);
|
||||
|
||||
await shoot(tester);
|
||||
|
||||
@@ -202,7 +202,39 @@ void main() {
|
||||
expect(tasks.single.albumId, 'alb-1');
|
||||
expect(tasks.single.categoryId, isNull, reason: '默认分类');
|
||||
expect(tasks.single.filename, 'IMG_0001.jpg');
|
||||
expect(find.text('Pending 1'), findsOneWidget, reason: '徽章实时刷新');
|
||||
expect(find.text('Queued 1'), findsOneWidget, reason: '徽章实时刷新');
|
||||
});
|
||||
|
||||
testWidgets('入队成功反馈:短暂浮层出现(不逐张弹确认,§4.5)',
|
||||
(tester) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
// 与 pressRealIo 同法但缩短真实等待(80ms < 200ms 反馈窗):入队链路
|
||||
// 完成时浮层已翻转、计时器未到期,可在窗内断言不透明度 > 0。
|
||||
final button = find
|
||||
.ancestor(of: find.byIcon(Icons.camera), matching: find.byType(IconButton))
|
||||
.evaluate()
|
||||
.single
|
||||
.widget as IconButton;
|
||||
await tester.runAsync(() async {
|
||||
button.onPressed!();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||
});
|
||||
await tester.pump(const Duration(milliseconds: 180));
|
||||
|
||||
// 反馈浮层淡入完成(AnimatedOpacity → 1),文案为「已加入队列」。
|
||||
expect(
|
||||
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity).first).opacity,
|
||||
greaterThan(0),
|
||||
reason: '200ms 反馈窗内浮层可见',
|
||||
);
|
||||
expect(find.text('Added to queue'), findsOneWidget);
|
||||
// 等待 200ms 反馈计时器在真实事件循环中到期,避免悬挂计时器;随后
|
||||
// fake pump 冲刷淡出动画帧。
|
||||
await tester.runAsync(
|
||||
() async => Future<void>.delayed(const Duration(milliseconds: 250)),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
});
|
||||
|
||||
testWidgets('连拍两张串行入队(不逐张确认)', (tester) async {
|
||||
@@ -215,7 +247,7 @@ void main() {
|
||||
'IMG_0001.jpg',
|
||||
'IMG_0002.jpg',
|
||||
]));
|
||||
expect(find.text('Pending 2'), findsOneWidget);
|
||||
expect(find.text('Queued 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('分类切换:hint 提示 + 仅影响之后拍摄(A15 归属不变)',
|
||||
|
||||
@@ -97,10 +97,10 @@ void main() {
|
||||
await pumpHome(tester);
|
||||
|
||||
// IndexedStack 常驻三页签(非当前页签 offstage,find 默认跳过,故显式
|
||||
// 关闭 skipOffstage)。Albums/Settings 各两次(页签 AppBar 标题 + 导航
|
||||
// 标签);Uploads 仅导航标签(队列页标题为 Upload queue)。
|
||||
// 关闭 skipOffstage)。各两次(页签 AppBar 标题 + 导航标签)——UI-01
|
||||
// 起上传中心页标题与导航标签同名 Uploads。
|
||||
expect(find.text('Albums', skipOffstage: false), findsNWidgets(2));
|
||||
expect(find.text('Uploads', skipOffstage: false), findsOneWidget);
|
||||
expect(find.text('Uploads', skipOffstage: false), findsNWidgets(2));
|
||||
expect(find.text('Settings', skipOffstage: false), findsNWidgets(2));
|
||||
expect(
|
||||
find.text('Queue', skipOffstage: false),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/// 上传队列页 widget 测试(P3 计划 Task 9,需求 4.3)。
|
||||
/// 上传中心 widget 测试(UI-01 §4.4,P3 计划 Task 9 基础上重构)。
|
||||
///
|
||||
/// 打桩面:RecordingEngine(记录 retryNow/cancel,不出队)+ 内存 TaskStore
|
||||
/// 种子 + FakeAdapter 相册列表 + 临时目录准备器(准备失败重试换源)。
|
||||
/// 覆盖:任务行渲染(状态徽章/错误文案/相册名)、三维筛选交互、按状态
|
||||
/// 动作(重试/取消/删除)、批量重试条、写通知防抖实时刷新、行级进度条。
|
||||
/// 覆盖:顶部摘要卡(范围标注 + 四组任务数)、四组状态筛选 chips、
|
||||
/// 任务行渲染(状态徽章/状态专属文案/错误文案/相册名)、按状态动作
|
||||
/// (重试/重新选择/取消/移除记录 + 确认弹窗)、批量重试条口径(仅
|
||||
/// 可重试失败)、写通知防抖实时刷新、行级进度条(分数 + 百分比文案)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -127,6 +129,11 @@ void main() {
|
||||
});
|
||||
|
||||
Future<void> pumpQueue(WidgetTester tester) async {
|
||||
// 加高视口:摘要卡 + 筛选栏 + 批量条压缩列表区,默认 800x600 下
|
||||
// 第二行落在懒构建区外(ListView.builder 不构建不可见项)。
|
||||
tester.view.physicalSize = const Size(800, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
@@ -213,6 +220,10 @@ void main() {
|
||||
return task;
|
||||
}
|
||||
|
||||
/// 行级锚定:任务行 Padding 带 ValueKey('queue-task-{task.id}')。
|
||||
Finder rowOf(String filename) =>
|
||||
find.byKey(ValueKey('queue-task-task-$filename'));
|
||||
|
||||
/// 等防抖重查落定(写通知 → 200ms Timer → 异步查询;不 pumpAndSettle——
|
||||
/// uploading 行的不确定态进度条动画不会停)。
|
||||
Future<void> settleDebounce(WidgetTester tester) async {
|
||||
@@ -220,6 +231,63 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
|
||||
/// 触发一个会走真实文件 IO 的按钮:在 runAsync(真实事件循环)内直接
|
||||
/// 调用其回调——widget 测试的 fake-async 区里 dart:io 完成回调不会被
|
||||
/// pump 冲刷,入队链路必须整体跑在真实区;返回后 pump 刷新徽章防抖。
|
||||
Future<void> pressButtonRealIo(WidgetTester tester, Finder button) async {
|
||||
final widget = button.evaluate().single.widget;
|
||||
final VoidCallback onPressed;
|
||||
if (widget is IconButton) {
|
||||
onPressed = widget.onPressed!;
|
||||
} else if (widget is TextButton) {
|
||||
onPressed = widget.onPressed!;
|
||||
} else {
|
||||
throw StateError('unsupported button type: $widget');
|
||||
}
|
||||
await tester.runAsync(() async {
|
||||
onPressed();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
});
|
||||
await tester.pump(kQueueRefreshDebounce);
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
|
||||
testWidgets('摘要卡:范围标注(时间·本设备)+ 四组任务数(按任务数口径)',
|
||||
(tester) async {
|
||||
await seed('done1.jpg', state: UploadTaskState.completed);
|
||||
await seed('done2.jpg', state: UploadTaskState.completed);
|
||||
await seed('up.jpg', state: UploadTaskState.uploading);
|
||||
await seed('q1.jpg');
|
||||
await seed('q2.jpg');
|
||||
await seed(
|
||||
'bad.jpg',
|
||||
state: UploadTaskState.retryableFailed,
|
||||
lastError: 'NETWORK_ERROR',
|
||||
);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 范围标注:未选相册 → 时间范围 · 本设备(跨设备记录在服务端历史页)。
|
||||
expect(find.text('Any time · This device'), findsOneWidget);
|
||||
|
||||
// 四组计数:摘要标签小写、chip 大写——find.text 区分大小写不冲突。
|
||||
void expectSummary(String label, String value) {
|
||||
final row = find.ancestor(
|
||||
of: find.text(label),
|
||||
matching: find.byType(Row),
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: row, matching: find.text(value)),
|
||||
findsOneWidget,
|
||||
reason: '摘要「$label」= $value',
|
||||
);
|
||||
}
|
||||
|
||||
expectSummary('completed', '2');
|
||||
expectSummary('uploading', '1');
|
||||
expectSummary('waiting', '2');
|
||||
expectSummary('need attention', '1');
|
||||
});
|
||||
|
||||
testWidgets('各状态任务行:徽章文案 + 相册名(缓存)+ 缩略图占位',
|
||||
(tester) async {
|
||||
await seed('prep.jpg', state: UploadTaskState.preparing);
|
||||
@@ -230,42 +298,18 @@ void main() {
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 状态徽章(en locale;断言锚定在行内——chip 栏文案与部分徽章同名)。
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.widgetWithText(ListTile, 'prep.jpg'),
|
||||
matching: find.text('Preparing'),
|
||||
)
|
||||
.evaluate(),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.widgetWithText(ListTile, 'ok.jpg'),
|
||||
matching: find.text('Completed'),
|
||||
)
|
||||
.evaluate(),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.widgetWithText(ListTile, 'net.jpg'),
|
||||
matching: find.text('Waiting for network'),
|
||||
)
|
||||
.evaluate(),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.widgetWithText(ListTile, 'alb2.jpg'),
|
||||
matching: find.text('Uploading'),
|
||||
)
|
||||
.evaluate(),
|
||||
isNotEmpty,
|
||||
);
|
||||
for (final (filename, badge) in [
|
||||
('prep.jpg', 'Preparing photo'),
|
||||
('ok.jpg', 'Completed'),
|
||||
('net.jpg', 'Waiting for network'),
|
||||
('alb2.jpg', 'Uploading'),
|
||||
]) {
|
||||
expect(
|
||||
find.descendant(of: rowOf(filename), matching: find.text(badge)),
|
||||
findsOneWidget,
|
||||
reason: '$filename 行徽章/状态文案 $badge',
|
||||
);
|
||||
}
|
||||
// 文件名。
|
||||
expect(find.text('ok.jpg'), findsOneWidget);
|
||||
// 相册名经 albums 缓存展示(en 主标题;3 条 alb-1 + 1 条 alb-2)。
|
||||
@@ -290,29 +334,102 @@ void main() {
|
||||
);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// §4.4:失败行显示本地化原因(独立错误行),状态行附尝试次数。
|
||||
expect(find.text('Photo exceeds the 50MB limit'), findsOneWidget);
|
||||
expect(find.text('Network error. Check your connection and retry'),
|
||||
findsOneWidget);
|
||||
expect(
|
||||
find.text('Network error. Check your connection and retry · '
|
||||
'2 attempt(s)'),
|
||||
find.descendant(
|
||||
of: rowOf('flaky.jpg'),
|
||||
matching: find.text('Upload failed'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: rowOf('flaky.jpg'),
|
||||
matching: find.text('2 attempt(s)'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('状态筛选:Failed chip → 仅失败行', (tester) async {
|
||||
testWidgets('等待网络行:徽章 + 自动恢复提示 + 保留手动重试', (tester) async {
|
||||
await seed(
|
||||
'net.jpg',
|
||||
state: UploadTaskState.waitingNetwork,
|
||||
lastError: 'NETWORK_ERROR',
|
||||
);
|
||||
await pumpQueue(tester);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: rowOf('net.jpg'),
|
||||
matching: find.text('Waiting for network'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
// §4.4:等待网络附自动恢复说明(用户无需手动干预)。
|
||||
expect(
|
||||
find.descendant(
|
||||
of: rowOf('net.jpg'),
|
||||
matching: find.text('Resumes automatically when the network recovers'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
// §4.4「保留已有重试」:等待网络行仍有刷新入口。
|
||||
expect(
|
||||
find.descendant(of: rowOf('net.jpg'), matching: find.byIcon(Icons.refresh)),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('状态筛选:Needs attention chip → 仅失败行', (tester) async {
|
||||
await seed('bad.jpg', state: UploadTaskState.preparationFailed);
|
||||
await seed('good.jpg', state: UploadTaskState.completed);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 状态 chips 行横向滚动——Failed 在屏外,先滚入再点。
|
||||
await tester.ensureVisible(find.text('Failed'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Failed'));
|
||||
// 状态 chip 锚定 ChoiceChip(行内徽章可能与 chip 同文案)。
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, 'Needs attention'));
|
||||
await settleDebounce(tester);
|
||||
|
||||
expect(find.text('bad.jpg'), findsOneWidget);
|
||||
expect(find.text('good.jpg'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('状态筛选:In progress chip → 活任务(含等待网络)',
|
||||
(tester) async {
|
||||
await seed('live.jpg');
|
||||
await seed('net.jpg', state: UploadTaskState.waitingNetwork);
|
||||
await seed('done.jpg', state: UploadTaskState.completed);
|
||||
await seed('bad.jpg', state: UploadTaskState.preparationFailed);
|
||||
await pumpQueue(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, 'In progress'));
|
||||
await settleDebounce(tester);
|
||||
|
||||
// 排队 + 等待网络属「进行中」(自动恢复范畴);终态与需关注排除。
|
||||
expect(find.text('live.jpg'), findsOneWidget);
|
||||
expect(find.text('net.jpg'), findsOneWidget);
|
||||
expect(find.text('done.jpg'), findsNothing);
|
||||
expect(find.text('bad.jpg'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('状态筛选:Completed chip → 仅已完成行', (tester) async {
|
||||
await seed('done.jpg', state: UploadTaskState.completed);
|
||||
await seed('gone.jpg', state: UploadTaskState.cancelled);
|
||||
await seed('q.jpg');
|
||||
await pumpQueue(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, 'Completed'));
|
||||
await settleDebounce(tester);
|
||||
|
||||
// §4.4:已完成组不含 cancelled(取消仅在「全部」可见)。
|
||||
expect(find.text('done.jpg'), findsOneWidget);
|
||||
expect(find.text('gone.jpg'), findsNothing);
|
||||
expect(find.text('q.jpg'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('时间筛选:Today → 昨日任务排除', (tester) async {
|
||||
final now = DateTime.now();
|
||||
await seed('fresh.jpg', createdAt: now);
|
||||
@@ -330,7 +447,8 @@ void main() {
|
||||
expect(find.text('old.jpg'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('相册筛选:下拉选中 alb-2 → 仅该相册行', (tester) async {
|
||||
testWidgets('相册筛选:下拉选中 alb-2 → 仅该相册行 + 摘要范围带相册名',
|
||||
(tester) async {
|
||||
await seed('one.jpg', albumId: 'alb-1');
|
||||
await seed('two.jpg', albumId: 'alb-2');
|
||||
await pumpQueue(tester);
|
||||
@@ -342,42 +460,43 @@ void main() {
|
||||
|
||||
expect(find.text('two.jpg'), findsOneWidget);
|
||||
expect(find.text('one.jpg'), findsNothing);
|
||||
// 摘要范围标注随筛选更新:相册名 · 时间 · 本设备。
|
||||
expect(find.text('Wedding Day · Any time · This device'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('按状态动作可见性:queued 取消 / completed 删除 / uploading 无',
|
||||
testWidgets('按状态动作可见性:queued 取消 / completed 移除记录 / uploading 无',
|
||||
(tester) async {
|
||||
await seed('q.jpg', state: UploadTaskState.queued);
|
||||
await seed('done.jpg', state: UploadTaskState.completed);
|
||||
await seed('up.jpg', state: UploadTaskState.uploading);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// queued 行 → 取消图标;completed 行 → 删除图标;uploading 行无动作。
|
||||
final cancelTile = find.widgetWithText(ListTile, 'q.jpg');
|
||||
final doneTile = find.widgetWithText(ListTile, 'done.jpg');
|
||||
final upTile = find.widgetWithText(ListTile, 'up.jpg');
|
||||
// queued 行 → 取消图标;completed 行 → 移除记录(弱化文字按钮);
|
||||
// uploading 行无动作(传输中不打断)。
|
||||
expect(
|
||||
find
|
||||
.descendant(of: cancelTile, matching: find.byIcon(Icons.close))
|
||||
.evaluate()
|
||||
.length,
|
||||
1,
|
||||
find.descendant(of: rowOf('q.jpg'), matching: find.byIcon(Icons.close)),
|
||||
findsOneWidget,
|
||||
reason: 'queued 行有取消按钮',
|
||||
);
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: doneTile,
|
||||
matching: find.byIcon(Icons.delete_outline),
|
||||
)
|
||||
.evaluate()
|
||||
.length,
|
||||
1,
|
||||
reason: 'completed 行有删除按钮',
|
||||
find.descendant(
|
||||
of: rowOf('done.jpg'),
|
||||
matching: find.text('Remove record'),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason: 'completed 行有移除记录按钮',
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: upTile, matching: find.byType(IconButton)).evaluate(),
|
||||
find.descendant(of: rowOf('up.jpg'), matching: find.byType(IconButton))
|
||||
.evaluate(),
|
||||
isEmpty,
|
||||
reason: 'uploading 行无动作(传输中不打断)',
|
||||
reason: 'uploading 行无图标动作(传输中不打断)',
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: rowOf('up.jpg'), matching: find.byType(TextButton))
|
||||
.evaluate(),
|
||||
isEmpty,
|
||||
reason: 'uploading 行无文字动作',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -387,7 +506,7 @@ void main() {
|
||||
await pumpQueue(tester);
|
||||
|
||||
await tester.tap(find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'q.jpg'),
|
||||
of: rowOf('q.jpg'),
|
||||
matching: find.byIcon(Icons.close),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -405,7 +524,7 @@ void main() {
|
||||
await pumpQueue(tester);
|
||||
|
||||
await tester.tap(find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'flaky.jpg'),
|
||||
of: rowOf('flaky.jpg'),
|
||||
matching: find.byIcon(Icons.refresh),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -413,7 +532,7 @@ void main() {
|
||||
expect(engine.retryCalls, [('alice', task.id)]);
|
||||
});
|
||||
|
||||
testWidgets('准备失败重试:重新选图 → 换源重新入队为 queued',
|
||||
testWidgets('准备失败:重新选图(非普通重试)→ 换源重新入队为 queued',
|
||||
(tester) async {
|
||||
final task = await seed(
|
||||
'broken.jpg',
|
||||
@@ -426,14 +545,21 @@ void main() {
|
||||
pickedPaths.add(picked.path);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// §4.4:准备失败行是「重新选择」(image_search 图标),不是刷新重试。
|
||||
expect(
|
||||
find.descendant(
|
||||
of: rowOf('broken.jpg'),
|
||||
matching: find.text('File unavailable'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
final selectAgain = find.descendant(
|
||||
of: rowOf('broken.jpg'),
|
||||
matching: find.widgetWithText(TextButton, 'Select again'),
|
||||
);
|
||||
|
||||
// 换源复制是真实文件 IO——runAsync 放行真实事件循环完成。
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'broken.jpg'),
|
||||
matching: find.byIcon(Icons.refresh),
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
});
|
||||
await pressButtonRealIo(tester, selectAgain);
|
||||
await settleDebounce(tester);
|
||||
|
||||
final updated = await store.get('alice', task.id);
|
||||
@@ -445,29 +571,49 @@ void main() {
|
||||
reason: '受管副本内容已替换为重选文件');
|
||||
});
|
||||
|
||||
testWidgets('删除流:completed 行删除 → 行消失 + 库内清除',
|
||||
testWidgets('移除记录:确认弹窗明确不删云端照片;确认后行消失 + 库内清除',
|
||||
(tester) async {
|
||||
final task = await seed('done.jpg', state: UploadTaskState.completed);
|
||||
await pumpQueue(tester);
|
||||
|
||||
await tester.tap(find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'done.jpg'),
|
||||
matching: find.byIcon(Icons.delete_outline),
|
||||
of: rowOf('done.jpg'),
|
||||
matching: find.text('Remove record'),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// §4.4:确认弹窗明确「仅清本机记录,不删云端照片」。
|
||||
expect(find.text('Remove this record?'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('Photos already uploaded are not deleted'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 取消 → 记录保留。
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('done.jpg'), findsOneWidget);
|
||||
|
||||
// 再删 → 确认 → 行消失。
|
||||
await tester.tap(find.descendant(
|
||||
of: rowOf('done.jpg'),
|
||||
matching: find.text('Remove record'),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Remove'));
|
||||
await settleDebounce(tester);
|
||||
|
||||
expect(find.text('done.jpg'), findsNothing);
|
||||
expect(await store.get('alice', task.id), isNull);
|
||||
});
|
||||
|
||||
testWidgets('批量重试条:出现条件 + 一键重试全部可自动重试任务',
|
||||
testWidgets('批量重试条:口径 = 可重试失败才重试(等待网络/准备失败不混入)',
|
||||
(tester) async {
|
||||
final auto1 = await seed(
|
||||
'f1.jpg',
|
||||
state: UploadTaskState.retryableFailed,
|
||||
lastError: 'INTERNAL_ERROR',
|
||||
);
|
||||
final auto2 = await seed(
|
||||
await seed(
|
||||
'f2.jpg',
|
||||
state: UploadTaskState.waitingNetwork,
|
||||
lastError: 'NETWORK_ERROR',
|
||||
@@ -479,23 +625,29 @@ void main() {
|
||||
);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 2 条可自动重试(retryableFailed + waitingNetwork)→ 批量条出现。
|
||||
// 批量条出现:标题计数与摘要「需关注」同口径(准备失败 + 上传失败),
|
||||
// 范围文案说明仅重试可自动重试的子集。
|
||||
expect(find.text('Retry all failed'), findsOneWidget);
|
||||
expect(find.text('Failed · 2'), findsOneWidget,
|
||||
reason: '失败计数 = 准备失败 + 上传失败(不含等待网络)');
|
||||
expect(find.text('Needs attention · 2'), findsOneWidget);
|
||||
expect(find.textContaining('Only failed uploads on this device'),
|
||||
findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Retry all failed'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(engine.retryCalls, containsAll([
|
||||
('alice', auto1.id),
|
||||
('alice', auto2.id),
|
||||
]));
|
||||
// §4.4:一键重试仅作用于 retryableFailed——等待网络自动恢复、准备
|
||||
// 失败需换源重选,均不进入批量重试。
|
||||
expect(engine.retryCalls, [('alice', auto1.id)]);
|
||||
expect(
|
||||
engine.retryCalls.map((c) => c.$2),
|
||||
isNot(contains('task-f3.jpg')),
|
||||
reason: '准备失败需换源重选,不进批量重试',
|
||||
);
|
||||
expect(
|
||||
engine.retryCalls.map((c) => c.$2),
|
||||
isNot(contains('task-f2.jpg')),
|
||||
reason: '等待网络自动恢复,不进批量重试',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('无失败任务 → 无批量条', (tester) async {
|
||||
@@ -518,7 +670,7 @@ void main() {
|
||||
expect(find.text('second.jpg'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('行级进度:uploading 行订阅进度总线,分数更新',
|
||||
testWidgets('行级进度:uploading 行订阅进度总线,分数 + 百分比文案更新',
|
||||
(tester) async {
|
||||
final task = await seed('up.jpg', state: UploadTaskState.uploading);
|
||||
await pumpQueue(tester);
|
||||
@@ -529,7 +681,7 @@ void main() {
|
||||
|
||||
LinearProgressIndicator indicatorOf() => find
|
||||
.descendant(
|
||||
of: find.widgetWithText(ListTile, 'up.jpg'),
|
||||
of: rowOf('up.jpg'),
|
||||
matching: find.byType(LinearProgressIndicator),
|
||||
)
|
||||
.evaluate()
|
||||
@@ -542,5 +694,13 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
expect(indicatorOf().value, 0.5, reason: '进度事件行级刷新为 50%');
|
||||
// §4.4:上传中行展示「上传中 {percent}%」文案。
|
||||
expect(
|
||||
find.descendant(
|
||||
of: rowOf('up.jpg'),
|
||||
matching: find.text('Uploading 50%'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,9 +114,9 @@ void main() {
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
|
||||
// A19 数据源区分横幅。
|
||||
// A19 数据源区分横幅(UI-01:本机队列入口文案指向「上传」页)。
|
||||
expect(find.textContaining('cross-device'), findsOneWidget);
|
||||
expect(find.textContaining('Queue tab'), findsOneWidget);
|
||||
expect(find.textContaining('Uploads tab'), findsOneWidget);
|
||||
|
||||
// 行:clientTaskId 前 8 位 + KB(2048B → 2 KB;5MB → 5120 KB)。
|
||||
expect(find.textContaining('owndevic'), findsOneWidget);
|
||||
|
||||
@@ -76,7 +76,7 @@ void main() {
|
||||
expect(find.byType(PasswordChangePage), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('退出登录 → api.logout + 清会话 + 清栈回 /login(服务端失败亦然)',
|
||||
testWidgets('退出登录:确认弹窗(本机任务保留说明)→ 确认后清会话回 /login',
|
||||
(tester) async {
|
||||
// 服务端撤销失败(500)也照样本地登出——尽力而为语义。
|
||||
// 首页「相册」页签(Task 4)进入即自动拉列表,需一并兜底路由。
|
||||
@@ -87,8 +87,25 @@ void main() {
|
||||
});
|
||||
await pumpHome(tester, clientOf(adapter));
|
||||
|
||||
// 点列表项 → 先弹确认(§4.5:明确本机任务保留,不承诺后台继续)。
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sign out?'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('preserved on this device'),
|
||||
findsOneWidget,
|
||||
reason: '确认文案明确本地任务保留、重新登录后继续',
|
||||
);
|
||||
// 相册页签进入即拉列表(见上注释),此处只约束未发生 logout。
|
||||
expect(
|
||||
adapter.requests.where((r) => r.path == '/v1/auth/logout'),
|
||||
isEmpty,
|
||||
reason: '未确认不调 logout',
|
||||
);
|
||||
|
||||
// 确认(弹窗内 FilledButton 与列表项同名,按按钮类型定位)。
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final logout = adapter.requests.singleWhere(
|
||||
(r) => r.path == '/v1/auth/logout',
|
||||
@@ -99,6 +116,45 @@ void main() {
|
||||
expect(find.byType(HomePage), findsNothing, reason: '主壳已出栈');
|
||||
});
|
||||
|
||||
testWidgets('设置分组:账号/语言/安全/上传帮助/关于(UI-01 §4.5)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
// 分组列表长于默认 800×600 视口(「关于」在折叠线下不可建树)。
|
||||
tester.view.physicalSize = const Size(800, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpHome(tester, clientOf(adapter));
|
||||
|
||||
// 五个分组标题(SectionHeader 文案)。
|
||||
for (final section in [
|
||||
'Account',
|
||||
'Language',
|
||||
'Security',
|
||||
'Upload help',
|
||||
'About',
|
||||
]) {
|
||||
expect(find.text(section), findsOneWidget, reason: '分组「$section」');
|
||||
}
|
||||
|
||||
// 账号分组:当前账号 + 说明副标题。
|
||||
expect(find.text('staff01'), findsOneWidget);
|
||||
expect(find.text('Currently signed-in account'), findsOneWidget);
|
||||
|
||||
// 上传帮助:展开后说明系统限制 / 强退 / 网络等待三分态(不承诺
|
||||
// 「后台上传已开启」)。
|
||||
await tester.tap(find.text('How background uploads work'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('battery limits'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('start automatically once a connection is available'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// 关于分组:版本 tile。
|
||||
expect(find.text('Version'), findsOneWidget);
|
||||
expect(find.text(kAppVersion), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('语言切换:底部弹层三选一 → 更新档位 + 持久化(P3 Task 10)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
@@ -115,14 +171,15 @@ void main() {
|
||||
|
||||
// 本 harness 的 MaterialApp 固定 en(live 全 App 重建在
|
||||
// app/locale_test.dart 的 PhotoLiveApp 用例覆盖)。
|
||||
expect(find.text('Language'), findsOneWidget);
|
||||
expect(find.text('Follow system'), findsOneWidget, reason: '默认档位副标题');
|
||||
expect(find.text('Language'), findsOneWidget, reason: '语言分组标题');
|
||||
expect(find.text('Follow system'), findsOneWidget, reason: '默认档位行标题');
|
||||
|
||||
await tester.tap(find.text('Language'));
|
||||
// 行标题即当前档位,点它打开弹层。
|
||||
await tester.tap(find.text('Follow system'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 弹层三选项(语言名以自身语言书写,跨 locale 不变)。
|
||||
// 「Follow system」出现两次:主档 tile 副标题 + 弹层选项。
|
||||
// 「Follow system」出现两次:主档行标题 + 弹层选项。
|
||||
expect(find.text('Follow system'), findsNWidgets(2));
|
||||
expect(find.text('中文'), findsOneWidget);
|
||||
expect(find.text('English'), findsOneWidget);
|
||||
@@ -130,7 +187,7 @@ void main() {
|
||||
await tester.tap(find.text('中文'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('中文'), findsOneWidget, reason: '副标题更新为当前档位');
|
||||
expect(find.text('中文'), findsOneWidget, reason: '行标题更新为当前档位');
|
||||
expect(localeStore.writes, ['zh'], reason: '选择写入 app.locale');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/// 分享二维码对话框 widget 测试(P3 计划 Task 4)。
|
||||
/// 分享二维码对话框 widget 测试(UI-01 §4.5 分层重构)。
|
||||
///
|
||||
/// 覆盖:QrImageView 以 viewer URL({VIEWER_BASE}/a/{token})渲染、
|
||||
/// URL 文本可见、复制按钮写入剪贴板并提示。
|
||||
/// 覆盖:分层结构(相册标题 → 白底二维码 → URL 文本 → 复制为主/浏览器
|
||||
/// 打开为次)、QrImageView 以 viewer URL({VIEWER_BASE}/a/{token})渲染、
|
||||
/// 复制按钮写入剪贴板并提示、「在浏览器中打开」经注入的替身启动器
|
||||
/// 打开 URL 并关闭对话框。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/config.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
@@ -15,22 +18,38 @@ import 'package:qr_flutter/qr_flutter.dart';
|
||||
void main() {
|
||||
const token = 'sharetoken000000000001';
|
||||
|
||||
Future<void> pumpDialog(WidgetTester tester) async {
|
||||
Future<void> pumpDialog(
|
||||
WidgetTester tester, {
|
||||
String? albumTitle,
|
||||
UrlLauncher? launcher,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
// 复制提示走 ScaffoldMessenger:包一层 Scaffold 供 SnackBar 挂载。
|
||||
home: const Scaffold(
|
||||
body: Center(child: ShareQrDialog(publicToken: token)),
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
if (launcher != null)
|
||||
urlLauncherProvider.overrideWith((ref) => launcher),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
// 复制提示走 ScaffoldMessenger:包一层 Scaffold 供 SnackBar 挂载。
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: ShareQrDialog(
|
||||
publicToken: token,
|
||||
albumTitle: albumTitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('渲染 QrImageView,data 为观众链接;URL 文本可见', (tester) async {
|
||||
await pumpDialog(tester);
|
||||
testWidgets('分层渲染:相册标题 → 二维码(data 为观众链接)→ URL 文本',
|
||||
(tester) async {
|
||||
await pumpDialog(tester, albumTitle: 'Opening Gala');
|
||||
|
||||
final url = ApiConfig.viewerAlbumUrl(token);
|
||||
// qr_flutter 4.1 未公开 QrImageView.data(私有 _data),以同源的
|
||||
@@ -41,10 +60,13 @@ void main() {
|
||||
url,
|
||||
);
|
||||
expect(url, 'http://10.0.2.2:5175/a/$token');
|
||||
// 分层:主标题 = 相册名,副标题 = 通用标题,脚注 = 扫码/链接提示。
|
||||
expect(find.text('Opening Gala'), findsOneWidget);
|
||||
expect(find.text('Share QR code'), findsOneWidget);
|
||||
expect(find.text('Scan the code or open the link to view'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('复制按钮 → 剪贴板收到 URL + 已复制提示', (tester) async {
|
||||
testWidgets('复制按钮(主操作)→ 剪贴板收到 URL + 已复制提示', (tester) async {
|
||||
final clipboardCalls = <MethodCall>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
@@ -69,4 +91,22 @@ void main() {
|
||||
);
|
||||
expect(find.text('Copied'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('在浏览器中打开(次操作)→ 启动器收到 URL + 关闭对话框',
|
||||
(tester) async {
|
||||
final launchedUrls = <String>[];
|
||||
await pumpDialog(
|
||||
tester,
|
||||
launcher: (url) async {
|
||||
launchedUrls.add(url);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open in browser'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launchedUrls, [ApiConfig.viewerAlbumUrl(token)]);
|
||||
expect(find.byType(ShareQrDialog), findsNothing, reason: '打开成功后关闭');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user