Files
photo-live/apps/mobile/lib/pages/server_history_page.dart
T
2026-09-22 02:06:23 +08:00

574 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 服务端上传历史页(UI-03 页面 16「Server records」)。
///
/// GET /uploads(operationId listUploads)游标分页展示**当前账号跨设备**
/// 的上传会话记录:标题 +「全部设备 · 当前账号」副标题明确**跨设备服务端
/// 口径**;顶部说明条强调本机待传队列在「上传」页——本机队列是 drift
/// 任务库(TaskStore),两者数据源与生命周期不同,不合并统计(A19)。
/// 相册/日期为客户端过滤(已加载记录内),分页按服务端原序继续;下拉
/// 刷新 + 加载更多失败可重试。
///
/// 记录不含 filename 与设备名(契约 UploadSessionRecord 无该字段),行首
/// 展示 clientTaskId 前 8 位作识别;设备来源做**安全回退**——sourceDeviceId
/// 与本机 [DeviceId] 一致标「本设备」,否则显示通用占位(不展示裸设备
/// ID,不编造设备名)。
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api_client.dart';
import '../core/app_exception.dart';
import '../core/device_id.dart';
import '../core/error_codes.dart';
import '../core/error_l10n.dart';
import '../features/albums/album_format.dart';
import '../features/albums/providers.dart';
import '../features/photos/models.dart';
import '../features/uploads/models.dart';
import '../features/uploads/queue_controller.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';
/// 服务端上传历史(从上传中心「History」入口进入;无根底部导航)。
class ServerHistoryPage extends ConsumerStatefulWidget {
const ServerHistoryPage({super.key});
@override
ConsumerState<ServerHistoryPage> createState() => _ServerHistoryPageState();
}
class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
static const int _pageSize = 20;
List<UploadSessionRecord> _items = const <UploadSessionRecord>[];
String? _nextCursor;
bool _hasMore = false;
bool _loading = true;
bool _loadingMore = false;
/// 加载更多失败(页脚重试行出现;重试成功后清除)。
bool _loadMoreFailed = false;
String? _loadMoreErrorCode;
String? _errorCode;
/// 客户端次级筛选(相册 × 时间;作用于已加载记录)。
String? _albumId;
QueueTimeFilter _time = QueueTimeFilter.all;
/// 本机设备标识(「本设备」标记比对,A19)。
String? _ownDeviceId;
@override
void initState() {
super.initState();
_reload();
unawaited(
ref.read(deviceIdProvider).value().then((id) {
if (mounted) setState(() => _ownDeviceId = id);
}),
);
}
Future<void> _reload() async {
setState(() {
_loading = true;
_errorCode = null;
_loadMoreFailed = false;
});
try {
final page = await ref
.read(apiClientProvider)
.listUploads(limit: _pageSize);
if (mounted) {
setState(() {
_items = page.items;
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_loading = false;
});
}
} on AppException catch (e) {
if (mounted) {
setState(() {
_errorCode = e.code;
_loading = false;
});
}
} on Object {
if (mounted) {
setState(() {
_errorCode = ErrorCodes.internalError;
_loading = false;
});
}
}
}
/// 下拉刷新:保留当前列表(不切换全局加载态);失败以 snackbar 提示。
Future<void> _refresh() async {
try {
final page = await ref
.read(apiClientProvider)
.listUploads(limit: _pageSize);
if (mounted) {
setState(() {
_items = page.items;
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_loadMoreFailed = false;
});
}
} on Object {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).errorGeneric),
),
);
}
}
}
Future<void> _loadMore() async {
if (_loading || _loadingMore || !_hasMore) return;
setState(() {
_loadingMore = true;
_loadMoreFailed = false;
});
try {
final page = await ref.read(apiClientProvider).listUploads(
cursor: _nextCursor,
limit: _pageSize,
);
if (mounted) {
setState(() {
_items = <UploadSessionRecord>[..._items, ...page.items];
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_loadingMore = false;
});
}
} on AppException catch (e) {
// 页脚重试行(§4 页面 16:加载失败可重试),不弹全局错误。
if (mounted) {
setState(() {
_loadingMore = false;
_loadMoreFailed = true;
_loadMoreErrorCode = e.code;
});
}
} on Object {
if (mounted) {
setState(() {
_loadingMore = false;
_loadMoreFailed = true;
_loadMoreErrorCode = ErrorCodes.internalError;
});
}
}
}
/// 已加载记录按相册 × 时间过滤(分页按服务端原序继续,不因过滤中断)。
List<UploadSessionRecord> get _visibleItems {
final cutoff = _time.since(DateTime.now());
return _items.where((record) {
if (_albumId != null && record.albumId != _albumId) return false;
if (cutoff != null && record.createdAt.toLocal().isBefore(cutoff)) {
return false;
}
return true;
}).toList();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final albums = ref.watch(albumListProvider).items;
final selectedAlbum = _albumId == null
? null
: albums.where((a) => a.id == _albumId).firstOrNull;
return Scaffold(
appBar: AppBar(
// 标题 + 跨设备副标题(§4 页面 16:Server records · All devices)。
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.serverRecords),
Text(
l10n.serverSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// A19 标注说明条:与「上传」页(本机任务库)的数据源区分。
Card(
margin: const EdgeInsets.fromLTRB(kSpace16, kSpace12, kSpace16, 0),
child: Padding(
padding: const EdgeInsets.all(kSpace12),
child: Row(
children: [
Icon(
Icons.devices_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: kSpace8),
Expanded(
child: Text(
l10n.serverHistoryBanner,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16, kSpace8, kSpace16, kSpace4),
child: Row(
children: [
PopupMenuButton<String>(
initialValue: _albumId ?? '',
onSelected: (albumId) => setState(() {
_albumId = albumId == '' ? null : albumId;
}),
itemBuilder: (context) => [
PopupMenuItem<String>(
value: '',
child: Text(l10n.queueAlbumFilterAll),
),
for (final album in albums)
PopupMenuItem<String>(
value: album.id,
child: Text(
albumDisplayTitle(album, l10n.localeName),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
child: _FilterPill(
icon: Icons.photo_album_outlined,
label: selectedAlbum == null
? l10n.queueAlbumFilterAll
: albumDisplayTitle(selectedAlbum, l10n.localeName),
),
),
const SizedBox(width: kSpace8),
PopupMenuButton<QueueTimeFilter>(
initialValue: _time,
onSelected: (time) => setState(() => _time = time),
itemBuilder: (context) => [
for (final time in QueueTimeFilter.values)
PopupMenuItem<QueueTimeFilter>(
value: time,
child: Text(switch (time) {
QueueTimeFilter.all => l10n.queueTimeAll,
QueueTimeFilter.today => l10n.uploadsToday,
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
QueueTimeFilter.last30Days =>
l10n.queueTimeLast30Days,
}),
),
],
child: _FilterPill(
icon: Icons.schedule_outlined,
label: switch (_time) {
QueueTimeFilter.all => l10n.queueTimeAll,
QueueTimeFilter.today => l10n.uploadsToday,
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
QueueTimeFilter.last30Days => l10n.queueTimeLast30Days,
},
),
),
],
),
),
Expanded(child: _buildBody(l10n)),
],
),
);
}
Widget _buildBody(AppLocalizations l10n) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorCode != null) {
return ErrorState(
message: l10n.messageForErrorCode(_errorCode!),
onRetry: _reload,
);
}
final visible = _visibleItems;
if (visible.isEmpty) {
return EmptyState(
icon: Icons.cloud_done_outlined,
title: l10n.serverHistoryEmpty,
);
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: visible.length + (_hasMore || _loadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= visible.length) {
if (_loadMoreFailed) {
return _LoadMoreFailedRow(
message: l10n.messageForErrorCode(
_loadMoreErrorCode ?? ErrorCodes.networkError,
),
onRetry: _loadMore,
);
}
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
// 预取触发;加载更多失败后停住——页脚重试行接管(不自动循环
// 重试,§4 页面 16)。
if (index >= visible.length - 3 &&
_hasMore &&
!_loadingMore &&
!_loadMoreFailed) {
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore());
}
return _sessionTile(context, visible[index]);
},
),
);
}
Widget _sessionTile(BuildContext context, UploadSessionRecord record) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
// createdAt 以本机时区展示(记录跨相册,无单一相册时区可依)。
final local = record.createdAt.toLocal();
final time = '${local.year}/${local.month}/${local.day} '
'${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
// 设备来源安全回退:本机 → 「本设备」;其余 → 通用占位(契约无设备
// 名,不展示裸 ID、不编造名称)。
final ownDevice =
_ownDeviceId != null && record.sourceDeviceId == _ownDeviceId;
final deviceLabel = ownDevice ? l10n.thisDevice : l10n.serverDeviceUnknown;
return Padding(
padding: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace12, kSpace8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 契约无图片 URL——中性占位(不伪造缩略图)。
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(kRadiusBadge),
),
child: Icon(
Icons.cloud_done_outlined,
size: 24,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: kSpace12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${record.clientTaskId.substring(0, 8)} · '
'${(record.expectedBytes / 1024).round()} KB',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(height: 2),
Text(
'$time · $deviceLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
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),
),
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),
),
],
),
],
),
);
}
static String _sessionStateLabel(
AppLocalizations l10n,
UploadSessionState state,
) {
switch (state) {
case UploadSessionState.pending:
return l10n.uploadStatePending;
case UploadSessionState.confirmed:
return l10n.serverConfirmed;
case UploadSessionState.cancelled:
return l10n.uploadStateCancelled;
case UploadSessionState.expired:
return l10n.uploadStateExpired;
}
}
static String _processingLabel(
AppLocalizations l10n,
PhotoProcessingState s,
) {
switch (s) {
case PhotoProcessingState.pendingUpload:
return l10n.procPendingUpload;
case PhotoProcessingState.uploaded:
return l10n.procUploaded;
case PhotoProcessingState.processing:
return l10n.serverProcessing;
case PhotoProcessingState.ready:
return l10n.serverReady;
case PhotoProcessingState.processingFailed:
return l10n.procProcessingFailed;
}
}
}
/// 筛选药丸(相册/日期入口):边框胶囊 + 展开箭头(§4 页面 16)。
///
/// 作为 [PopupMenuButton.child] 使用——点击弹下拉菜单选择筛选值。
class _FilterPill extends StatelessWidget {
const _FilterPill({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
);
}
}
/// 加载更多失败页脚:错误说明 + 重试(§4 页面 16)。
class _LoadMoreFailedRow extends StatelessWidget {
const _LoadMoreFailedRow({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: kSpace16, vertical: 8),
child: Row(
children: [
Icon(Icons.cloud_off, size: 16, color: theme.colorScheme.error),
const SizedBox(width: kSpace8),
Expanded(
child: Text(
message,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 16),
label: Text(l10n.retry),
),
],
),
);
}
}