Files
photo-live/apps/mobile/lib/pages/server_history_page.dart
T
2026-09-21 17:20:43 +08:00

311 lines
10 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-01 §4.4 主题统一,P3 计划 Task 6 / A19 语义)。
///
/// GET /uploads(operationId listUploads)游标分页展示**当前账号跨设备**
/// 的上传会话记录:会话状态 / photo.processingState / sourceDeviceId /
/// 创建时间。页面标题即「服务端历史(跨设备)」,顶部说明条明确「本机
/// 待传队列在上传页」——本机队列是 drift 任务库(TaskStore),两者数据源
/// 与生命周期不同,不合并统计(A19 标注要求)。
///
/// 记录不含 filename(契约 UploadSessionRecord 无该字段),行首展示
/// clientTaskId 前 8 位作识别;sourceDeviceId 与本机 [DeviceId] 一致时
/// 标记「本设备」。
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/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 {
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;
String? _errorCode;
/// 本机设备标识(「本设备」标记比对,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;
});
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;
});
}
}
}
Future<void> _loadMore() async {
if (_loading || _loadingMore || !_hasMore) return;
setState(() => _loadingMore = true);
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 {
if (mounted) {
setState(() => _loadingMore = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)
.messageForErrorCode(ErrorCodes.networkError),
),
),
);
}
} on Object {
if (mounted) {
setState(() => _loadingMore = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).errorGeneric),
),
);
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.serverHistoryTitle)),
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,
),
),
),
],
),
),
),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _errorCode != null
? ErrorState(
message: l10n.messageForErrorCode(_errorCode!),
onRetry: _reload,
)
: _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]);
},
),
),
],
),
);
}
Widget _sessionTile(
AppLocalizations l10n,
ThemeData theme,
UploadSessionRecord record,
) {
// 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')}';
final deviceShort = record.sourceDeviceId?.substring(0, 8);
final ownDevice =
_ownDeviceId != null && record.sourceDeviceId == _ownDeviceId;
return ListTile(
leading: const Icon(Icons.cloud_done_outlined),
title: Text(
'${record.clientTaskId.substring(0, 8)} · '
'${(record.expectedBytes / 1024).round()} KB',
style: theme.textTheme.bodyMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
subtitle: Text(
deviceShort == null
? time
: '$time · $deviceShort${ownDevice ? '(${l10n.thisDevice})' : ''}',
),
trailing: 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.uploadStateConfirmed;
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.procProcessing;
case PhotoProcessingState.ready:
return l10n.procReady;
case PhotoProcessingState.processingFailed:
return l10n.procProcessingFailed;
}
}
}