1134 lines
34 KiB
Dart
1134 lines
34 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/main.dart' show routeObserver;
|
|
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,
|
|
this.showBackButton = true,
|
|
this.onTaskStatusChanged,
|
|
});
|
|
|
|
final bool showBackButton;
|
|
final VoidCallback? onTaskStatusChanged;
|
|
|
|
@override
|
|
State<TaskPage> createState() => _TaskPageState();
|
|
}
|
|
|
|
class _TaskPageState extends State<TaskPage>
|
|
with WidgetsBindingObserver, RouteAware {
|
|
static const String _taskGiftAsset = 'sc_images/index/sc_icon_task_gift.png';
|
|
static const Duration _taskStatusCacheRefreshDelay = Duration(seconds: 65);
|
|
|
|
final SCAccountRepository _repository = SCAccountRepository();
|
|
final Set<String> _claimedTaskIds = {};
|
|
final Set<String> _claimingTaskIds = {};
|
|
List<SCTaskListRes> _tasks = [];
|
|
bool _loading = true;
|
|
bool _loadingTasks = false;
|
|
String? _toastMessage;
|
|
Timer? _toastTimer;
|
|
Timer? _cacheRefreshTimer;
|
|
PageRoute<dynamic>? _routeObserverRoute;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_loadTasks();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
routeObserver.unsubscribe(this);
|
|
_toastTimer?.cancel();
|
|
_cacheRefreshTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
final route = ModalRoute.of(context);
|
|
if (route is PageRoute<dynamic> && _routeObserverRoute != route) {
|
|
if (_routeObserverRoute != null) {
|
|
routeObserver.unsubscribe(this);
|
|
}
|
|
_routeObserverRoute = route;
|
|
routeObserver.subscribe(this, route);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
unawaited(_loadTasks());
|
|
_scheduleTaskStatusCacheRefresh('app resumed');
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didPopNext() {
|
|
unawaited(_loadTasks());
|
|
_scheduleTaskStatusCacheRefresh('route return');
|
|
}
|
|
|
|
Future<void> _loadTasks() async {
|
|
if (_loadingTasks) {
|
|
return;
|
|
}
|
|
_loadingTasks = true;
|
|
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;
|
|
setState(() {
|
|
_tasks = sortedTasks;
|
|
_loading = false;
|
|
});
|
|
widget.onTaskStatusChanged?.call();
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_tasks = [];
|
|
_loading = false;
|
|
});
|
|
} finally {
|
|
_loadingTasks = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _handleManualRefresh() async {
|
|
await _loadTasks();
|
|
_scheduleTaskStatusCacheRefresh('manual refresh');
|
|
}
|
|
|
|
void _scheduleTaskStatusCacheRefresh(String reason) {
|
|
_cacheRefreshTimer?.cancel();
|
|
_cacheRefreshTimer = Timer(_taskStatusCacheRefreshDelay, () {
|
|
_cacheRefreshTimer = null;
|
|
if (!mounted) return;
|
|
unawaited(_loadTasks());
|
|
});
|
|
}
|
|
|
|
@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: _handleManualRefresh,
|
|
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:
|
|
widget.showBackButton
|
|
? 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,
|
|
),
|
|
),
|
|
)
|
|
: SizedBox(width: 44.w, height: 44.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,
|
|
) {
|
|
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 parsedTargetValue =
|
|
_parseNumber(task.targetValue) ?? _parseNumber(task.conditionValue);
|
|
final targetValue = parsedTargetValue ?? 0;
|
|
final hasProgress = parsedTargetValue != null && parsedTargetValue > 0;
|
|
final claimed =
|
|
_claimedTaskIds.contains(id) || (task.isRewardCollected ?? 0) == 1;
|
|
final completedByValue = hasProgress && 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: _taskProgressText(
|
|
task,
|
|
currentValue: currentValue,
|
|
targetValue: targetValue,
|
|
hasProgress: hasProgress,
|
|
),
|
|
requirementText: _taskRequirementText(
|
|
task,
|
|
targetValue: targetValue,
|
|
hasProgress: hasProgress,
|
|
),
|
|
rewardText: _formatPlainNumber(task.quantity ?? 1100),
|
|
status: status,
|
|
iconAsset: _iconForTask(task),
|
|
iconUrl: _nonEmpty(task.taskIcon) ?? _nonEmpty(task.cover),
|
|
jumpPage: _nonEmpty(task.jumpPage),
|
|
jumpType: _nonEmpty(task.jumpType),
|
|
conditionType: _nonEmpty(task.conditionType),
|
|
roomId: _nonEmpty(task.roomId),
|
|
roomTask: _isRoomCompletionTask(task),
|
|
gameTask: _isGameCompletionTask(task),
|
|
fromApi: id.isNotEmpty,
|
|
);
|
|
}
|
|
|
|
Future<void> _handleTaskAction(_TaskUiItem task) async {
|
|
if (task.status == _TaskActionStatus.claimed) return;
|
|
if (task.status == _TaskActionStatus.go) {
|
|
final opened = await _openJumpPage(task);
|
|
if (opened && mounted) {
|
|
unawaited(_loadTasks());
|
|
_scheduleTaskStatusCacheRefresh('jump return');
|
|
}
|
|
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<bool> _openJumpPage(_TaskUiItem task) async {
|
|
var target = _taskJumpUrl(task);
|
|
if (target.isEmpty) return false;
|
|
var uri = Uri.tryParse(target);
|
|
var path = uri?.path ?? '';
|
|
var roomId = uri?.queryParameters['roomId']?.trim() ?? '';
|
|
if ((path == '/room' || path == '/game-center') && roomId.isEmpty) {
|
|
final fallbackTarget = await _firstRoomJumpUrl(target);
|
|
if (!mounted) return false;
|
|
if (fallbackTarget.isEmpty) {
|
|
_showToast(SCAppLocalizations.of(context)!.enterRoomFailedRetry);
|
|
return false;
|
|
}
|
|
target = fallbackTarget;
|
|
uri = Uri.tryParse(target);
|
|
path = uri?.path ?? '';
|
|
roomId = uri?.queryParameters['roomId']?.trim() ?? '';
|
|
}
|
|
|
|
final jumpType = _taskJumpType(task, target);
|
|
await SCEntryPopupCoordinator.openConfiguredJump(
|
|
context,
|
|
jumpType: jumpType,
|
|
jumpUrl: target,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
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);
|
|
return resolvedTarget;
|
|
}
|
|
} catch (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) {
|
|
return _defaultTaskJumpUrl(task, 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 _defaultTaskJumpUrl(_TaskUiItem task, String roomId) {
|
|
if (_isGameJumpType(task.jumpType) || task.gameTask) {
|
|
return _routeWithOptionalRoomId('/game-center', roomId);
|
|
}
|
|
if (_isRoomJumpType(task.jumpType) || task.roomTask) {
|
|
return _routeWithOptionalRoomId('/room', roomId);
|
|
}
|
|
if (_isRechargeTask(task)) {
|
|
return '/recharge';
|
|
}
|
|
return roomId.isEmpty ? '' : _routeWithOptionalRoomId('/room', roomId);
|
|
}
|
|
|
|
String _routeWithOptionalRoomId(String route, String roomId) {
|
|
if (roomId.isEmpty) return route;
|
|
return '$route?roomId=${Uri.encodeComponent(roomId)}';
|
|
}
|
|
|
|
String _taskJumpType(_TaskUiItem task, String target) {
|
|
final uri = Uri.tryParse(target);
|
|
final path = uri?.path ?? '';
|
|
final jumpType = task.jumpType?.trim();
|
|
if (jumpType != null && jumpType.isNotEmpty) {
|
|
if (_isGameJumpType(jumpType)) return 'GAME';
|
|
if (_isRoomJumpType(jumpType)) return 'APP_ROUTE';
|
|
}
|
|
if (uri?.hasScheme == true) {
|
|
return 'H5';
|
|
}
|
|
if (path == '/game-center') {
|
|
return 'GAME';
|
|
}
|
|
if (path.startsWith('/')) {
|
|
return 'APP_ROUTE';
|
|
}
|
|
if (jumpType != null && jumpType.isNotEmpty) return jumpType;
|
|
return 'APP_ROUTE';
|
|
}
|
|
|
|
bool _isRoomJumpType(String? jumpType) {
|
|
final normalized = jumpType?.trim().toUpperCase() ?? '';
|
|
return normalized == 'ROOM' ||
|
|
normalized == 'VOICE_ROOM' ||
|
|
normalized == 'VOICECHAT_ROOM' ||
|
|
normalized == 'ENTER_ROOM' ||
|
|
normalized == 'JOIN_ROOM';
|
|
}
|
|
|
|
bool _isGameJumpType(String? jumpType) {
|
|
final normalized = jumpType?.trim().toUpperCase() ?? '';
|
|
return normalized == 'GAME' ||
|
|
normalized == 'GAME_CENTER' ||
|
|
normalized == 'GAME_CONSUME_GOLD';
|
|
}
|
|
|
|
void _showToast(String message) {
|
|
_toastTimer?.cancel();
|
|
setState(() => _toastMessage = message);
|
|
_toastTimer = Timer(const Duration(milliseconds: 1600), () {
|
|
if (mounted) setState(() => _toastMessage = null);
|
|
});
|
|
}
|
|
|
|
String _iconForTask(SCTaskListRes task) {
|
|
return _taskGiftAsset;
|
|
}
|
|
|
|
String _taskProgressText(
|
|
SCTaskListRes task, {
|
|
required num currentValue,
|
|
required num targetValue,
|
|
required bool hasProgress,
|
|
}) {
|
|
if (!hasProgress) return '';
|
|
final l10n = SCAppLocalizations.of(context)!;
|
|
return '${l10n.taskProgress}: '
|
|
'${_formatTaskValue(task, currentValue)}/${_formatTaskValue(task, targetValue)}';
|
|
}
|
|
|
|
String _taskRequirementText(
|
|
SCTaskListRes task, {
|
|
required num targetValue,
|
|
required bool hasProgress,
|
|
}) {
|
|
final l10n = SCAppLocalizations.of(context)!;
|
|
if (hasProgress) {
|
|
return '${l10n.taskRequirement}: ${_formatTaskValue(task, targetValue)}';
|
|
}
|
|
return _nonEmpty(task.taskDesc) ?? '';
|
|
}
|
|
|
|
bool _isGameCompletionTask(SCTaskListRes task) {
|
|
final conditionType = task.conditionType?.trim().toUpperCase() ?? '';
|
|
if (conditionType.contains('GAME')) return true;
|
|
final text = '${task.taskName ?? ''} ${task.taskDesc ?? ''}'.toLowerCase();
|
|
return text.contains('game') || text.contains('游戏');
|
|
}
|
|
|
|
bool _isRoomCompletionTask(SCTaskListRes task) {
|
|
final conditionType = task.conditionType?.trim().toUpperCase() ?? '';
|
|
if (conditionType.contains('RECHARGE')) return false;
|
|
if (_isGameCompletionTask(task)) return false;
|
|
if (conditionType.contains('MIC') ||
|
|
conditionType.contains('VOICE') ||
|
|
conditionType.contains('ROOM') ||
|
|
conditionType.contains('GIFT') ||
|
|
conditionType.contains('CONSUME')) {
|
|
return true;
|
|
}
|
|
|
|
final text = '${task.taskName ?? ''} ${task.taskDesc ?? ''}'.toLowerCase();
|
|
if (text.contains('recharge') || text.contains('充值')) return false;
|
|
if (text.contains('game') || text.contains('游戏')) return false;
|
|
return text.contains('mic') ||
|
|
text.contains('voice') ||
|
|
text.contains('room') ||
|
|
text.contains('gift') ||
|
|
text.contains('consume') ||
|
|
text.contains('spend') ||
|
|
text.contains('上麦') ||
|
|
text.contains('房间') ||
|
|
text.contains('语音') ||
|
|
text.contains('礼物') ||
|
|
text.contains('消费');
|
|
}
|
|
|
|
bool _isRechargeTask(_TaskUiItem task) {
|
|
final text =
|
|
'${task.conditionType ?? ''} ${task.title} ${task.jumpType ?? ''}'
|
|
.toLowerCase();
|
|
return text.contains('recharge') || text.contains('充值');
|
|
}
|
|
|
|
static num? _parseNumber(Object? value) {
|
|
if (value is num) return value;
|
|
if (value is String) {
|
|
final text = value.trim();
|
|
if (text.isEmpty) return null;
|
|
return num.tryParse(text) ?? num.tryParse(text.replaceAll(',', ''));
|
|
}
|
|
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);
|
|
}
|
|
|
|
String _formatTaskValue(SCTaskListRes task, num value) {
|
|
if (_isTimeValueTask(task)) return _formatDurationSeconds(value);
|
|
return _formatPlainNumber(value);
|
|
}
|
|
|
|
bool _isTimeValueTask(SCTaskListRes task) {
|
|
final conditionType = task.conditionType?.trim().toUpperCase() ?? '';
|
|
if (conditionType.contains('GOLD') || conditionType.contains('CONSUME')) {
|
|
return false;
|
|
}
|
|
if (conditionType.contains('SECOND') ||
|
|
conditionType.contains('DURATION') ||
|
|
RegExp(r'(^|_)TIME($|_)').hasMatch(conditionType)) {
|
|
return true;
|
|
}
|
|
|
|
final text = '${task.taskName ?? ''} ${task.taskDesc ?? ''}'.toLowerCase();
|
|
return text.contains('second') ||
|
|
text.contains('seconds') ||
|
|
text.contains('minute') ||
|
|
text.contains('minutes') ||
|
|
text.contains('hour') ||
|
|
text.contains('hours') ||
|
|
text.contains(' min') ||
|
|
text.contains('秒') ||
|
|
text.contains('分钟') ||
|
|
text.contains('小时');
|
|
}
|
|
|
|
static String _formatDurationSeconds(num seconds) {
|
|
final absSeconds = seconds.abs();
|
|
if (absSeconds >= 3600) {
|
|
return '${_formatShortDecimal(seconds / 3600)}h';
|
|
}
|
|
if (absSeconds >= 60) {
|
|
return '${_formatShortDecimal(seconds / 60)}min';
|
|
}
|
|
return '${_formatShortDecimal(seconds)}s';
|
|
}
|
|
|
|
static String _formatShortDecimal(num value) {
|
|
final fixed = value.toStringAsFixed(1);
|
|
return fixed.endsWith('.0') ? fixed.substring(0, fixed.length - 2) : fixed;
|
|
}
|
|
|
|
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},'
|
|
'condition=${task.conditionType},'
|
|
'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: 76.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),
|
|
if (task.requirementText.isNotEmpty) ...[
|
|
Text(
|
|
task.requirementText,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.72),
|
|
fontSize: 10.sp,
|
|
fontWeight: FontWeight.w400,
|
|
height: 1,
|
|
),
|
|
),
|
|
SizedBox(height: 3.w),
|
|
],
|
|
if (task.progressText.isNotEmpty) ...[
|
|
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: 5.w),
|
|
] else
|
|
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.requirementText,
|
|
required this.rewardText,
|
|
required this.status,
|
|
required this.iconAsset,
|
|
this.iconUrl,
|
|
this.jumpPage,
|
|
this.jumpType,
|
|
this.conditionType,
|
|
this.roomId,
|
|
this.roomTask = false,
|
|
this.gameTask = false,
|
|
this.fromApi = false,
|
|
});
|
|
|
|
final String id;
|
|
final String title;
|
|
final String progressText;
|
|
final String requirementText;
|
|
final String rewardText;
|
|
final _TaskActionStatus status;
|
|
final String iconAsset;
|
|
final String? iconUrl;
|
|
final String? jumpPage;
|
|
final String? jumpType;
|
|
final String? conditionType;
|
|
final String? roomId;
|
|
final bool roomTask;
|
|
final bool gameTask;
|
|
final bool fromApi;
|
|
|
|
_TaskUiItem copyWith({_TaskActionStatus? status}) {
|
|
return _TaskUiItem(
|
|
id: id,
|
|
title: title,
|
|
progressText: progressText,
|
|
requirementText: requirementText,
|
|
rewardText: rewardText,
|
|
status: status ?? this.status,
|
|
iconAsset: iconAsset,
|
|
iconUrl: iconUrl,
|
|
jumpPage: jumpPage,
|
|
jumpType: jumpType,
|
|
conditionType: conditionType,
|
|
roomId: roomId,
|
|
roomTask: roomTask,
|
|
gameTask: gameTask,
|
|
fromApi: fromApi,
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _TaskActionStatus { go, claim, claimed }
|