FunyMeeAI/lib/features/history/history_screen.dart

461 lines
15 KiB
Dart

import 'dart:async';
import 'package:client_proxy_framework/client_proxy_framework.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/app_env.dart';
import '../../core/auth/auth_service.dart';
import '../../core/user/user_state.dart';
import '../../design/pencil_theme.dart';
import '../../widgets/pencil_chrome.dart';
import '../../widgets/pencil_yellow_white_background.dart';
import '../generate/generate_result_screen.dart';
import '../profile/delete_account_flow.dart';
import 'credit_record_tab.dart';
import 'history_media_save.dart';
import 'history_task_progress_screen.dart';
import 'widgets/history_grid_card.dart';
/// `WBRp4` My History — 黄→白渐变、顶栏、双 Tab、24h 提示、双列卡片。
class HistoryScreen extends StatefulWidget {
const HistoryScreen({
super.key,
this.isRootTab = false,
this.isTabSelected = true,
});
/// When true (e.g. bottom tab), hide back button; when pushed, show back.
final bool isRootTab;
/// Bottom shell: only `true` when the History tab is selected — defers `my-tasks` load.
/// Pushed routes should default `true` so load runs as before.
final bool isTabSelected;
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
int _tab = 0;
bool _loading = false;
String? _error;
List<MyTaskItem> _items = [];
Map<String, String> _localCovers = {};
final Set<String> _downloadingTaskIds = {};
final Set<String> _deletingTaskIds = {};
VoidCallback? _cancelLoginWait;
@override
void initState() {
super.initState();
_cancelLoginWait = AuthService.whenLoginSucceeded(
onReady: _tryLoadTasks,
onFailed: () {
if (!mounted || !widget.isTabSelected) return;
setState(() {
_loading = false;
_error = 'Sign in failed';
});
},
);
}
@override
void didUpdateWidget(HistoryScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (!oldWidget.isTabSelected && widget.isTabSelected) {
_tryLoadTasks();
}
}
@override
void dispose() {
_cancelLoginWait?.call();
super.dispose();
}
/// `GET /v1/image/my-tasks` only when this tab is visible and login succeeded.
void _tryLoadTasks() {
if (!widget.isTabSelected) return;
if (!AuthService.isLoginComplete.value) return;
final uid = UserState.userId.value;
if (uid == null || uid.isEmpty) return;
_load();
}
int? _parseTaskIdInt(String raw) {
final t = raw.trim();
if (t.isEmpty) return null;
return int.tryParse(t);
}
/// 调用 [ImageApi.deleteTask];成功后从列表与本地封面映射移除。
Future<void> _confirmAndDeleteTask(MyTaskItem item) async {
final idStr = item.taskId?.trim() ?? '';
final taskId = _parseTaskIdInt(idStr);
if (taskId == null) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Invalid task id')));
return;
}
final confirmed = await showPencilWhiteDangerConfirmDialog(
context,
title: 'Confirm deletion',
body: 'This item will be removed from your history. Continue?',
);
if (!confirmed || !mounted) return;
if (_deletingTaskIds.contains(idStr)) return;
setState(() => _deletingTaskIds.add(idStr));
dynamic res;
try {
res = await ImageApi.deleteTask(taskId: taskId);
} catch (_) {
res = null;
} finally {
if (mounted) {
setState(() => _deletingTaskIds.remove(idStr));
}
}
if (!mounted) return;
if (res == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Delete failed. Please try again.')),
);
return;
}
if (res.isSuccess != true) {
final msg = '${res.msg ?? ''}'.trim();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(msg.isNotEmpty ? msg : 'Delete failed')),
);
return;
}
setState(() {
_items = _items
.where((e) => int.tryParse(e.taskId?.trim() ?? '') != taskId)
.toList();
_localCovers.removeWhere(
(k, _) => k.trim() == idStr || int.tryParse(k.trim()) == taskId,
);
});
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Deleted')));
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
final res = await ImageApi.getMyTasks(
app: currentBackendAppType(),
page: '1',
pageSize: '30',
);
if (!mounted) return;
if (!res.isSuccess || res.data == null) {
setState(() {
_loading = false;
_error = res.msg.isNotEmpty ? res.msg : 'Failed to load';
});
return;
}
final tasks = res.data!.tasks ?? [];
final locals = await ImageTaskHistory.localCoverPathsForMyTaskItems(tasks);
setState(() {
_loading = false;
_items = tasks;
_localCovers = locals;
});
}
@override
Widget build(BuildContext context) {
return PencilYellowWhitePageBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
bottom: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: SizedBox(
height: 58,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
PencilRoundCloseButton(
onPressed: () => Navigator.maybePop(context),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_headerTab('My History', 0),
const SizedBox(width: 26),
_headerTab('Credit Record', 1),
],
),
),
const SizedBox(width: 44),
],
),
),
),
),
Expanded(
child: _tab == 0
? _myHistoryBody()
: CreditRecordTab(
extraBottomInset: widget.isRootTab
? PencilTheme.mainTabBottomChromeReserve(context)
: 0,
),
),
],
),
),
),
);
}
Widget _headerTab(String label, int index) {
final selected = _tab == index;
return InkWell(
onTap: () => setState(() => _tab = index),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: GoogleFonts.inter(
fontSize: 17,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
fontStyle: FontStyle.italic,
color: selected ? PencilTheme.ink : PencilTheme.inkSoft,
),
),
const SizedBox(height: 8),
if (selected)
Container(
width: 50,
height: 4,
decoration: BoxDecoration(
color: PencilTheme.underlineGold,
borderRadius: BorderRadius.circular(2),
),
)
else
const SizedBox(height: 4),
],
),
);
}
Widget _myHistoryBody() {
if (!widget.isTabSelected) {
return const SizedBox.shrink();
}
final shellBottom = widget.isRootTab
? PencilTheme.mainTabBottomChromeReserve(context)
: 0.0;
if (!AuthService.isLoginComplete.value) {
return Padding(
padding: EdgeInsets.only(bottom: shellBottom),
child: const Center(child: CircularProgressIndicator()),
);
}
final uid = UserState.userId.value;
if (uid == null || uid.isEmpty) {
return Padding(
padding: EdgeInsets.only(bottom: shellBottom),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error ?? 'Sign in failed', textAlign: TextAlign.center),
],
),
),
);
}
if (_loading) {
return Padding(
padding: EdgeInsets.only(bottom: shellBottom),
child: const Center(child: CircularProgressIndicator()),
);
}
if (_error != null) {
return Padding(
padding: EdgeInsets.only(bottom: shellBottom),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
TextButton(onPressed: _load, child: const Text('Retry')),
],
),
),
);
}
return RefreshIndicator(
onRefresh: _load,
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
sliver: SliverToBoxAdapter(child: _expiryNotice()),
),
if (_items.isEmpty)
SliverFillRemaining(
child: Padding(
padding: EdgeInsets.only(bottom: shellBottom),
child: Center(
child: Text(
'No tasks yet.',
style: GoogleFonts.inter(color: PencilTheme.inkSoft),
),
),
),
)
else
SliverPadding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 28 + shellBottom),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 14,
crossAxisSpacing: 14,
childAspectRatio: 171 / 182,
),
delegate: SliverChildBuilderDelegate((context, i) {
final t = _items[i];
final id = t.taskId ?? '';
final raw = myTaskListingRaw(t);
final display = listingDisplayFromApi(raw);
final canDl = myTaskCanShowDownload(t);
final statusLabel = myTaskStatusLabel(t);
return HistoryGridCard(
item: t,
localCoverPath: id.isEmpty ? null : _localCovers[id],
showDownload: canDl,
statusLabel: statusLabel,
isDownloading:
id.isNotEmpty && _downloadingTaskIds.contains(id),
isDeleting:
id.isNotEmpty && _deletingTaskIds.contains(id),
onDelete: id.isEmpty
? null
: () {
unawaited(_confirmAndDeleteTask(t));
},
onTap: () {
if (id.isEmpty) return;
// 有结果 URL 优先进预览(与列表「完成」态以地址为准一致)
if (myTaskHasRemoteResultUrl(t)) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => GenerateResultScreen(
taskId: id,
resultUrl: t.resultUrl?.trim() ?? '',
),
),
);
return;
}
if (myTaskIsInProgress(t)) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
HistoryTaskProgressScreen(taskId: id),
),
);
return;
}
if (galleryListingIsFinishedSuccess(raw, display)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Media is not ready yet. Pull to refresh.',
),
),
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
galleryListingBlockedHint(raw, display),
),
),
);
},
onDownload: canDl
? () async {
final u = t.resultUrl?.trim() ?? '';
if (u.isEmpty || id.isEmpty) return;
setState(() => _downloadingTaskIds.add(id));
try {
await saveHistoryMediaToGallery(
context: context,
taskId: id,
resultUrl: u,
);
} finally {
if (mounted) {
setState(() => _downloadingTaskIds.remove(id));
}
}
}
: null,
);
}, childCount: _items.length),
),
),
],
),
);
}
Widget _expiryNotice() {
return Container(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
decoration: BoxDecoration(
color: PencilTheme.expiryBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: PencilTheme.expiryBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'24-hour expiry',
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: PencilTheme.expiryHead,
),
),
const SizedBox(height: 4),
Text(
'Each item is kept for 24 hours after creation. Download before it expires.',
style: GoogleFonts.inter(
fontSize: 11,
height: 1.35,
color: PencilTheme.expiryBody,
),
),
],
),
);
}
}