/// 服务端上传历史页(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'; /// 服务端上传历史(从队列页占位入口进入)。 class ServerHistoryPage extends ConsumerStatefulWidget { const ServerHistoryPage({super.key}); @override ConsumerState createState() => _ServerHistoryPageState(); } class _ServerHistoryPageState extends ConsumerState { static const int _pageSize = 20; List _items = const []; 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 _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 _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 = [..._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(12, 12, 12, 0), child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ Icon( Icons.devices_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 8), Expanded( child: Text( l10n.serverHistoryBanner, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ), ), ), Expanded( child: _loading ? const Center(child: CircularProgressIndicator()) : _items.isEmpty && _errorCode != null ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.cloud_off, size: 48), const SizedBox(height: 12), Text(l10n.messageForErrorCode(_errorCode!)), const SizedBox(height: 12), FilledButton.icon( onPressed: _reload, icon: const Icon(Icons.refresh), label: Text(l10n.retry), ), ], ), ) : ListView.builder( itemCount: _items.length + (_items.isEmpty || _hasMore || _loadingMore ? 1 : 0), itemBuilder: (context, index) { if (_items.isEmpty) { return Padding( padding: const EdgeInsets.all(32), child: Text( l10n.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, ), ), ), ); } 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: [ _HistoryTag( label: _sessionStateLabel(l10n, record.state), highlight: record.state == UploadSessionState.confirmed, ), _HistoryTag( label: _processingLabel(l10n, record.photo.processingState), highlight: record.photo.processingState == PhotoProcessingState.ready, ), ], ), ); } 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; } } } /// 会话记录标签(会话状态/处理状态)。 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, ), ), ); } }