yumi-flutter/lib/modules/user/task/task_page.dart
2026-05-08 19:41:18 +08:00

946 lines
28 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/shared/business_logic/models/res/sc_task_list_res.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/tools/sc_entry_popup_coordinator.dart';
class TaskPage extends StatefulWidget {
const TaskPage({super.key});
@override
State<TaskPage> createState() => _TaskPageState();
}
class _TaskPageState extends State<TaskPage> {
static const String _taskMicAsset = 'sc_images/index/sc_icon_task_mic.png';
static const String _taskGameAsset = 'sc_images/index/sc_icon_task_game.png';
static const String _taskGiftAsset = 'sc_images/index/sc_icon_task_gift.png';
static const String _taskDefaultAsset = 'sc_images/index/sc_icon_task.png';
final SCAccountRepository _repository = SCAccountRepository();
final Set<String> _claimedTaskIds = {};
final Set<String> _claimingTaskIds = {};
List<SCTaskListRes> _tasks = [];
bool _loading = true;
String? _toastMessage;
Timer? _toastTimer;
@override
void initState() {
super.initState();
_loadTasks();
}
@override
void dispose() {
_toastTimer?.cancel();
super.dispose();
}
Future<void> _loadTasks() async {
debugPrint('[TaskCenter][Page] load start');
try {
final tasks = await _repository.tasks();
if (!mounted) return;
final sortedTasks =
tasks..sort((a, b) => (a.sortOrder ?? 0).compareTo(b.sortOrder ?? 0));
final dailyCount =
sortedTasks.where((task) => (task.taskType ?? 0).toInt() == 0).length;
final exclusiveCount = sortedTasks.length - dailyCount;
debugPrint(
'[TaskCenter][Page] load success total=${sortedTasks.length} '
'daily=$dailyCount exclusive=$exclusiveCount '
'sample=${_taskDebugSample(sortedTasks)}',
);
setState(() {
_tasks = sortedTasks;
_loading = false;
});
} catch (error) {
debugPrint('[TaskCenter][Page] load failed error=$error');
if (!mounted) return;
setState(() {
_tasks = [];
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final dailyTasks = _sectionTasks(isNewcomer: false);
final newcomerTasks = _sectionTasks(isNewcomer: true);
return Scaffold(
backgroundColor: const Color(0xFF00221D),
body: SafeArea(
bottom: false,
child: Stack(
children: [
Column(
children: [
_buildTopBar(),
Expanded(
child: RefreshIndicator(
color: const Color(0xFF16DBB2),
backgroundColor: const Color(0xFF06342A),
onRefresh: _loadTasks,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
padding: EdgeInsets.fromLTRB(10.w, 18.w, 10.w, 28.w),
child: Column(
children: [
if (_loading)
Padding(
padding: EdgeInsets.only(top: 120.w),
child: SizedBox(
width: 24.w,
height: 24.w,
child: const CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF16DBB2),
),
),
)
else
..._buildTaskSections(dailyTasks, newcomerTasks),
],
),
),
),
),
],
),
if (_toastMessage != null) _buildToast(),
],
),
),
);
}
Widget _buildTopBar() {
return SizedBox(
height: 44.w,
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => SCNavigatorUtils.goBack(context),
child: SizedBox(
width: 44.w,
height: 44.w,
child: Icon(
SCGlobalConfig.lang == 'ar'
? Icons.keyboard_arrow_right
: Icons.keyboard_arrow_left,
color: Colors.white,
size: 28.w,
),
),
),
),
Text(
SCAppLocalizations.of(context)!.task,
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildToast() {
return Positioned(
left: 50.w,
right: 50.w,
top: 316.w,
child: IgnorePointer(
child: Container(
height: 56.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF063F36),
borderRadius: BorderRadius.circular(12.w),
border: Border.all(color: const Color(0xFF18DDB5), width: 1.w),
),
child: Text(
_toastMessage!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 13.sp,
fontWeight: FontWeight.w400,
),
),
),
),
);
}
List<Widget> _buildTaskSections(
List<_TaskUiItem> dailyTasks,
List<_TaskUiItem> newcomerTasks,
) {
debugPrint(
'[TaskCenter][Page] build sections raw=${_tasks.length} '
'daily=${dailyTasks.length} exclusive=${newcomerTasks.length}',
);
final sections = <Widget>[];
final l10n = SCAppLocalizations.of(context)!;
if (dailyTasks.isNotEmpty) {
sections.add(
_TaskSection(
title: l10n.dailyTasks,
subtitle: l10n.resetsDailyAtMidnight,
tasks: dailyTasks,
onAction: _handleTaskAction,
isClaiming: _claimingTaskIds.contains,
),
);
}
if (newcomerTasks.isNotEmpty) {
if (sections.isNotEmpty) sections.add(SizedBox(height: 14.w));
sections.add(
_TaskSection(
title: l10n.exclusiveForNewcomers,
subtitle: l10n.limitedToOneTime,
tasks: newcomerTasks,
onAction: _handleTaskAction,
isClaiming: _claimingTaskIds.contains,
),
);
}
if (sections.isEmpty) {
sections.add(
Padding(
padding: EdgeInsets.only(top: 120.w),
child: Text(
l10n.noPromptsToday,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 14.sp,
fontWeight: FontWeight.w500,
),
),
),
);
}
return sections;
}
List<_TaskUiItem> _sectionTasks({required bool isNewcomer}) {
final source =
_tasks.where((task) {
final taskType = (task.taskType ?? 0).toInt();
return isNewcomer ? taskType != 0 : taskType == 0;
}).toList();
return source.map(_mapTask).toList();
}
_TaskUiItem _mapTask(SCTaskListRes task) {
final id = task.taskId ?? '';
final currentValue = _parseNumber(task.completedValue) ?? 0;
final targetValue =
_parseNumber(task.targetValue) ??
_parseNumber(task.conditionValue) ??
1000000;
final claimed =
_claimedTaskIds.contains(id) || (task.isRewardCollected ?? 0) == 1;
final completedByValue = targetValue > 0 && currentValue >= targetValue;
final completedByStatus = (task.taskStatus ?? 0) == 1;
final status =
claimed
? _TaskActionStatus.claimed
: (completedByStatus || completedByValue)
? _TaskActionStatus.claim
: _TaskActionStatus.go;
return _TaskUiItem(
id: id,
title:
_nonEmpty(task.taskName) ??
SCAppLocalizations.of(context)!.useMicrophoneForOneMin,
progressText:
_nonEmpty(task.taskDesc) ??
'${_formatPlainNumber(currentValue)}/${_formatPlainNumber(targetValue)}',
rewardText: _formatCompactNumber(task.quantity ?? 1100),
status: status,
iconAsset: _iconForTask(task),
iconUrl: _nonEmpty(task.taskIcon) ?? _nonEmpty(task.cover),
jumpPage: _nonEmpty(task.jumpPage),
jumpType: _nonEmpty(task.jumpType),
roomId: _nonEmpty(task.roomId),
fromApi: id.isNotEmpty,
);
}
Future<void> _handleTaskAction(_TaskUiItem task) async {
debugPrint(
'[TaskCenter][Page] action id=${task.id} status=${task.status.name} '
'fromApi=${task.fromApi} jumpType=${task.jumpType} '
'jumpPage=${task.jumpPage} roomId=${task.roomId}',
);
if (task.status == _TaskActionStatus.claimed) return;
if (task.status == _TaskActionStatus.go) {
await _openJumpPage(task);
return;
}
if (_claimingTaskIds.contains(task.id)) return;
setState(() => _claimingTaskIds.add(task.id));
var success = true;
if (task.fromApi && task.id.isNotEmpty) {
try {
success = await _repository.taskReward(task.id);
} catch (_) {
success = false;
}
}
if (!mounted) return;
setState(() {
_claimingTaskIds.remove(task.id);
if (success) _claimedTaskIds.add(task.id);
});
if (success) {
_showToast(SCAppLocalizations.of(context)!.rewardClaimedSuccessfully);
unawaited(_loadTasks());
} else {
_showToast(SCAppLocalizations.of(context)!.rewardClaimFailed);
}
}
Future<void> _openJumpPage(_TaskUiItem task) async {
var target = _taskJumpUrl(task);
if (target.isEmpty) return;
var uri = Uri.tryParse(target);
var path = uri?.path ?? '';
var roomId = uri?.queryParameters['roomId']?.trim() ?? '';
if ((path == '/room' || path == '/game-center') && roomId.isEmpty) {
debugPrint(
'[TaskCenter][Page] room jump missing roomId, fallback first room '
'target=$target taskId=${task.id}',
);
final fallbackTarget = await _firstRoomJumpUrl(target);
if (!mounted) return;
if (fallbackTarget.isEmpty) {
_showToast(SCAppLocalizations.of(context)!.enterRoomFailedRetry);
return;
}
target = fallbackTarget;
uri = Uri.tryParse(target);
path = uri?.path ?? '';
roomId = uri?.queryParameters['roomId']?.trim() ?? '';
}
final jumpType = _taskJumpType(task, target);
debugPrint(
'[TaskCenter][Page] open jump type=$jumpType url=$target '
'taskId=${task.id}',
);
await SCEntryPopupCoordinator.openConfiguredJump(
context,
jumpType: jumpType,
jumpUrl: target,
);
}
Future<String> _firstRoomJumpUrl(String target) async {
try {
final rooms = await SCChatRoomRepository().discovery(allRegion: true);
for (final room in rooms) {
final roomId = room.id?.trim() ?? '';
if (roomId.isEmpty) continue;
final resolvedTarget = _withRoomId(target, roomId);
debugPrint(
'[TaskCenter][Page] fallback first room roomId=$roomId '
'target=$resolvedTarget',
);
return resolvedTarget;
}
debugPrint('[TaskCenter][Page] fallback first room empty list');
} catch (error) {
debugPrint('[TaskCenter][Page] fallback first room failed error=$error');
}
return '';
}
String _withRoomId(String target, String roomId) {
final uri = Uri.tryParse(target);
if (uri == null) {
return '/room?roomId=${Uri.encodeComponent(roomId)}';
}
final params = Map<String, String>.from(uri.queryParameters);
params['roomId'] = roomId;
return uri.replace(queryParameters: params).toString();
}
String _taskJumpUrl(_TaskUiItem task) {
final target = task.jumpPage?.trim() ?? '';
final roomId = task.roomId?.trim() ?? '';
if (target.isEmpty) {
if (_isGameJumpType(task.jumpType)) {
return roomId.isEmpty
? '/game-center'
: '/game-center?roomId=${Uri.encodeComponent(roomId)}';
}
if (_isRoomJumpType(task.jumpType)) {
return roomId.isEmpty
? '/room'
: '/room?roomId=${Uri.encodeComponent(roomId)}';
}
return roomId.isEmpty
? ''
: '/room?roomId=${Uri.encodeComponent(roomId)}';
}
if (roomId.isEmpty) return target;
final uri = Uri.tryParse(target);
if (uri == null) return target;
if (uri.path != '/room' && uri.path != '/game-center') return target;
return _withRoomId(target, roomId);
}
String _taskJumpType(_TaskUiItem task, String target) {
final jumpType = task.jumpType?.trim();
if (jumpType != null && jumpType.isNotEmpty) {
if (_isGameJumpType(jumpType)) return 'GAME';
if (_isRoomJumpType(jumpType)) return 'APP_ROUTE';
return jumpType;
}
final uri = Uri.tryParse(target);
if (uri?.hasScheme == true) {
return 'H5';
}
if (uri?.path == '/game-center') {
return 'GAME';
}
return 'APP_ROUTE';
}
bool _isRoomJumpType(String? jumpType) {
final normalized = jumpType?.trim().toUpperCase() ?? '';
return normalized == 'ROOM' ||
normalized == 'VOICE_ROOM' ||
normalized == 'VOICECHAT_ROOM';
}
bool _isGameJumpType(String? jumpType) {
return jumpType?.trim().toUpperCase() == 'GAME';
}
void _showToast(String message) {
_toastTimer?.cancel();
setState(() => _toastMessage = message);
_toastTimer = Timer(const Duration(milliseconds: 1600), () {
if (mounted) setState(() => _toastMessage = null);
});
}
String _iconForTask(SCTaskListRes task) {
final text =
'${task.conditionType ?? ''} ${task.taskName ?? ''} ${task.taskDesc ?? ''}'
.toLowerCase();
if (text.contains('gift')) return _taskGiftAsset;
if (text.contains('game')) return _taskGameAsset;
if (text.contains('mic')) return _taskMicAsset;
return _taskDefaultAsset;
}
static num? _parseNumber(Object? value) {
if (value is num) return value;
if (value is String) return num.tryParse(value);
return null;
}
static String? _nonEmpty(String? value) {
final text = value?.trim();
return text == null || text.isEmpty ? null : text;
}
static String _formatPlainNumber(num value) {
if (value % 1 == 0) return value.toInt().toString();
return value.toStringAsFixed(1);
}
static String _formatCompactNumber(num value) {
final absValue = value.abs();
if (absValue >= 1000000) {
return '${_trimTrailingZero(value / 1000000)}m';
}
if (absValue >= 1000) {
return '${_trimTrailingZero(value / 1000)}k';
}
return _formatPlainNumber(value);
}
static String _trimTrailingZero(num value) {
final text = value.toStringAsFixed(1);
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
}
String _taskDebugSample(List<SCTaskListRes> tasks) {
if (tasks.isEmpty) return 'empty';
return tasks
.take(3)
.map((task) {
return '{id=${task.taskId},type=${task.taskType},'
'status=${task.taskStatus},collected=${task.isRewardCollected},'
'current=${task.completedValue},target=${task.targetValue},'
'quantity=${task.quantity},name=${task.taskName},'
'jumpType=${task.jumpType},jumpPage=${task.jumpPage},'
'roomId=${task.roomId}}';
})
.join(' ');
}
}
class _TaskSection extends StatelessWidget {
const _TaskSection({
required this.title,
required this.subtitle,
required this.tasks,
required this.onAction,
required this.isClaiming,
});
final String title;
final String subtitle;
final List<_TaskUiItem> tasks;
final ValueChanged<_TaskUiItem> onAction;
final bool Function(String id) isClaiming;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF052E25),
borderRadius: BorderRadius.circular(7.w),
border: Border.all(color: const Color(0xFFB4FCCE), width: 1.w),
),
child: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(12.w, 24.w, 12.w, 12.w),
child: Row(
children: [
Expanded(child: _GradientTaskTitle(title)),
SizedBox(width: 8.w),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w400,
height: 1,
),
),
],
),
),
for (int i = 0; i < tasks.length; i++) ...[
_TaskRow(
task: tasks[i],
onAction: onAction,
claiming: isClaiming(tasks[i].id),
),
if (i != tasks.length - 1)
Padding(
padding: EdgeInsetsDirectional.only(start: 62.w),
child: Divider(
height: 1.w,
thickness: 0.5.w,
color: const Color(0xFF295346).withValues(alpha: 0.8),
),
),
],
],
),
);
}
}
class _GradientTaskTitle extends StatelessWidget {
const _GradientTaskTitle(this.text);
final String text;
@override
Widget build(BuildContext context) {
final style = TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w900,
height: 1,
);
return LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
height: 23.w,
child: Stack(
clipBehavior: Clip.none,
children: [
PositionedDirectional(
top: 3.w,
start: 0,
end: 0,
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(color: const Color(0xFF157D10)),
),
),
PositionedDirectional(
start: 0,
end: 0,
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(
foreground:
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.w
..color = const Color(0xFF157D10),
),
),
),
PositionedDirectional(
start: 0,
end: 0,
child: ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback:
(bounds) => const LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [
Color(0xFFFFFFFF),
Color(0xFFBEFAE8),
Color(0xFFFFFFFF),
],
stops: [0.224, 0.4907, 0.7224],
).createShader(bounds),
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(color: Colors.white),
),
),
),
],
),
);
},
);
}
}
class _TaskRow extends StatelessWidget {
const _TaskRow({
required this.task,
required this.onAction,
required this.claiming,
});
final _TaskUiItem task;
final ValueChanged<_TaskUiItem> onAction;
final bool claiming;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 65.w,
child: Row(
children: [
SizedBox(width: 12.w),
Container(
width: 42.w,
height: 42.w,
decoration: const BoxDecoration(
color: Color(0x30BEF9E8),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: _TaskIcon(task: task),
),
SizedBox(width: 20.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w400,
height: 1.15,
),
),
SizedBox(height: 3.w),
Text(
task.progressText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 10.sp,
fontWeight: FontWeight.w400,
height: 1,
),
),
SizedBox(height: 6.w),
Row(
children: [
Image.asset(
'sc_images/general/sc_icon_jb.png',
width: 12.w,
height: 12.w,
),
SizedBox(width: 5.w),
Text(
task.rewardText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: const Color(0xFFFFD155),
fontSize: 12.sp,
fontWeight: FontWeight.w400,
height: 1,
),
),
],
),
],
),
),
SizedBox(width: 10.w),
_TaskActionButton(
status: task.status,
claiming: claiming,
onTap: () => onAction(task),
),
SizedBox(width: 12.w),
],
),
);
}
}
class _TaskActionButton extends StatelessWidget {
const _TaskActionButton({
required this.status,
required this.claiming,
required this.onTap,
});
final _TaskActionStatus status;
final bool claiming;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final config = _TaskButtonConfig.fromStatus(
status,
SCAppLocalizations.of(context)!,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: claiming ? null : onTap,
child: Container(
width: config.width.w,
height: 20.w,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.w),
border:
config.borderColor == null
? null
: Border.all(color: config.borderColor!, width: 1.w),
gradient: config.gradient,
color: config.gradient == null ? config.backgroundColor : null,
),
child:
claiming
? SizedBox(
width: 10.w,
height: 10.w,
child: CircularProgressIndicator(
strokeWidth: 1.5.w,
color: config.textColor,
),
)
: Text(
config.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: config.textColor,
fontSize: 13.sp,
fontWeight: FontWeight.w400,
height: 1,
),
),
),
);
}
}
class _TaskIcon extends StatelessWidget {
const _TaskIcon({required this.task});
final _TaskUiItem task;
@override
Widget build(BuildContext context) {
final iconUrl = task.iconUrl?.trim();
if (iconUrl == null || iconUrl.isEmpty) {
return _buildAssetIcon();
}
return Image.network(
iconUrl,
width: 29.4.w,
height: 29.4.w,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _buildAssetIcon(),
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return _buildAssetIcon();
},
);
}
Widget _buildAssetIcon() {
return Image.asset(
task.iconAsset,
width: 29.4.w,
height: 29.4.w,
fit: BoxFit.contain,
);
}
}
class _TaskButtonConfig {
const _TaskButtonConfig({
required this.label,
required this.textColor,
required this.width,
this.backgroundColor,
this.borderColor,
this.gradient,
});
final String label;
final Color textColor;
final double width;
final Color? backgroundColor;
final Color? borderColor;
final Gradient? gradient;
factory _TaskButtonConfig.fromStatus(
_TaskActionStatus status,
SCAppLocalizations l10n,
) {
switch (status) {
case _TaskActionStatus.claim:
return _TaskButtonConfig(
label: l10n.claim,
textColor: Color(0xFF2B1500),
width: 53,
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFD981), Color(0xFFFF9E2D)],
),
);
case _TaskActionStatus.claimed:
return _TaskButtonConfig(
label: l10n.claimed,
textColor: Colors.white,
width: 58,
backgroundColor: const Color(0xFF062E28),
borderColor: Colors.white.withValues(alpha: 0.56),
);
case _TaskActionStatus.go:
return _TaskButtonConfig(
label: l10n.go,
textColor: Colors.white,
width: 53,
backgroundColor: const Color(0xFF062E28),
borderColor: const Color(0xFF98E4C2),
);
}
}
}
class _TaskUiItem {
const _TaskUiItem({
required this.id,
required this.title,
required this.progressText,
required this.rewardText,
required this.status,
required this.iconAsset,
this.iconUrl,
this.jumpPage,
this.jumpType,
this.roomId,
this.fromApi = false,
});
final String id;
final String title;
final String progressText;
final String rewardText;
final _TaskActionStatus status;
final String iconAsset;
final String? iconUrl;
final String? jumpPage;
final String? jumpType;
final String? roomId;
final bool fromApi;
_TaskUiItem copyWith({_TaskActionStatus? status}) {
return _TaskUiItem(
id: id,
title: title,
progressText: progressText,
rewardText: rewardText,
status: status ?? this.status,
iconAsset: iconAsset,
iconUrl: iconUrl,
jumpPage: jumpPage,
jumpType: jumpType,
roomId: roomId,
fromApi: fromApi,
);
}
}
enum _TaskActionStatus { go, claim, claimed }