461 lines
15 KiB
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|