Files
Ichino/lib/pages/music_risk_feature_view.dart
T
Cookiues 80df51c239 fix: 解歌的 isNewItemList 按主键回读再算,不再一律发 1
排查「发 11820 解歌不显示」时发现两处问题:

- applyItemListPatch 的 isNewItemList 一直硬写全 '1'(新增),从没读服务器
  已有行。真机 BuildListData 是按 (itemKind, itemId) 比对快照给 0/1 的,
  重复解锁一首已存在的歌时服务器按插入冲突丢行,表现就是上传成功但没生效。
  这和角色/地图/万花筒三处的处理不一致,现补齐。
- 新增 TitleApiService.getUserItems/getUserItemKeys。GetUserItemApi 是按
  itemKind 分页的,首次 nextIndex 要按 itemKind * 1e10 编码
  (PacketGetUserItem.cs:19),发 0 只会拿到空页。
- 解锁上传后回读复查,把「服务器没存这行道具」与「存了但曲目池不给进」分开报。
- 待解锁列表含 10000~19999 的 DX 曲时提示:Master/Re:Master 不像老曲那样
  白送(IsUnlockMaster 里 id<10000 直接放过),必须显式带 itemKind 6/7。

需要说明:一首歌进不进选曲池由 NotesListManager.cs:70 决定
(IsOpenEvent(eventName) && !IsGameNgMusicId),事件窗口来自 GetGameEventApi、
曲目本体来自机台 Music.xml。11820 若卡在这一层,任何用户包都改不动,
复查那一步就是为了把这种情况和包的问题区分开。

README 补 isNew*List 语义、曲目可见性链路与 DX 段解锁规则三条说明。
删除因此不再被引用的 unlockMusicSuccess。
2026-10-01 22:14:50 +08:00

950 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart';
import '../widgets/step_progress.dart';
/// Dart port of `eaquira/action/UnlockMusic.py`:
///
/// - 拉取 GetUserData/Extend/Option/Rating/Charge/Activity/MissionData
/// - 组装 UserAll payload (musicData 使用 eaquira settings 中的默认值)
/// - 附加道具 (userItemList / isNewItemList)
/// - UpsertUserAllApi 上传
/// - 可选自动 UserLogout
enum MusicRiskFeatureMode {
/// 解锁歌曲与谱面 (itemKind 5/6/7)
unlockMusic,
/// 收藏品获取 (itemKind 1/2/3/10/11/12)
collectibles,
}
/// 一行待提交的收藏品道具(对应 wire 上的 UserItem)。
class _PendingItem {
final int kind;
final int id;
const _PendingItem(this.kind, this.id);
}
/// 一行待提交的歌曲解锁,展开成 itemKind 5/6/7 的若干行。
class _PendingMusic {
final int musicId;
final bool base;
final bool master;
final bool remaster;
const _PendingMusic(this.musicId, this.base, this.master, this.remaster);
bool get hasAnyOption => base || master || remaster;
/// 难度由 itemKind 表达,不写在 musicDetail 上。
List<int> get itemKinds => [
if (base) 5,
if (master) 6,
if (remaster) 7,
];
String get optionLabel => [
if (base) '歌曲',
if (master) 'Master',
if (remaster) 'Re:Master',
].join('/');
}
class MusicRiskFeatureView extends StatefulWidget {
final int userId;
final String? cookies;
/// `loginDateTime` 来自 HomePage 启动时执行的 UserLoginApi。
/// 为 `null` 时表示当前未持有有效登录态(例如 isInherit 账号或登录失败)。
final int? loginDateTime;
/// UserLoginApi 返回的 loginId / lastLoginDate, 组装 UserAll 需要。
final int? loginId;
final String? lastLoginDate;
/// 完成后自动退出登录并返回标题页时调用;由 HomePage 提供,
/// 内部会等待结算、发 UserLogoutApi、重置会话并 pop 回主标题。
final Future<void> Function()? onExitToTitle;
final MusicRiskFeatureMode mode;
final String featureTitle;
final String featureDesc;
const MusicRiskFeatureView({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.lastLoginDate,
this.onExitToTitle,
this.mode = MusicRiskFeatureMode.unlockMusic,
required this.featureTitle,
required this.featureDesc,
});
@override
State<MusicRiskFeatureView> createState() => _MusicRiskFeatureViewState();
}
class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
with CooldownMixin<MusicRiskFeatureView> {
// ── 解锁选项 (UnlockMusic) ──
final _unlockMusicIdController = TextEditingController();
bool _unlockMusic = false;
bool _unlockMaster = false;
bool _unlockRemaster = false;
// ── 收藏品选项 (Collectibles) ──
final _itemIdController = TextEditingController();
int _itemKind = 3;
// ── 待提交列表:userItemList 可以一次带多行 ──
// 真客户端的 ExportUserItems() 就是把 Plate/Title/Icon/Partner/Frame/Ticket 与
// MusicUnlock/Master/Remaster 各列表拼成多行 UserItem,BuildListData 再只发增量、
// isNewItemList 一行一个字符。所以多行同类/异类混发是客户端自己的做法。
final List<_PendingItem> _pendingItems = [];
final List<_PendingMusic> _pendingMusics = [];
// ── 运行状态 ──
Map<String, Map<String, dynamic>>? _userAllData;
bool _fetching = false;
String? _fetchError;
bool _running = false;
bool _autoLogout = true;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
String? _error;
bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic;
/// ConstParameter.DxScoreBaseID=10000 / StrongScoreBaseID=20000
/// (`MAI2System/ConstParameter.cs:168,171`)。这段里的歌 Master 需要
/// 显式 itemKind 6,不像 id < 10000 那样在 IsUnlockMaster 里直接放过。
bool _isDeluxeRange(_PendingMusic music) =>
music.musicId >= 10000 && music.musicId < 20000;
@override
int? get cooldownLoginDateTime => widget.loginDateTime;
@override
void dispose() {
_unlockMusicIdController.dispose();
_itemIdController.dispose();
super.dispose();
}
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
int _unlockMusicId() =>
int.tryParse(_unlockMusicIdController.text.trim()) ?? -1;
int _itemId() => int.tryParse(_itemIdController.text.trim()) ?? -1;
void _snack(String message) => context.showSnack(message);
/// 把当前输入框里的收藏品加入待提交列表。
void _addItemToList() {
final id = _itemId();
if (id <= 0) {
_snack(AppStrings.collectiblesNeedItemId);
return;
}
if (_pendingItems.any((e) => e.kind == _itemKind && e.id == id)) {
_snack(AppStrings.listDuplicate);
return;
}
setState(() {
_pendingItems.add(_PendingItem(_itemKind, id));
_itemIdController.clear();
});
}
/// 把当前输入框里的歌曲+勾选的难度加入待解锁列表。
void _addMusicToList() {
final id = _unlockMusicId();
if (id <= 0) {
_snack(AppStrings.unlockNeedMusicId);
return;
}
if (!_unlockMusic && !_unlockMaster && !_unlockRemaster) {
_snack(AppStrings.unlockNeedOption);
return;
}
if (_pendingMusics.any((e) => e.musicId == id)) {
_snack(AppStrings.listDuplicate);
return;
}
setState(() {
_pendingMusics.add(
_PendingMusic(id, _unlockMusic, _unlockMaster, _unlockRemaster),
);
_unlockMusicIdController.clear();
_unlockMusic = _unlockMaster = _unlockRemaster = false;
});
}
/// eaquira UnlockMusic.py 使用 settings 中的默认 musicData。
/// 解锁状态本身由 userItemList 的 itemKind 5/6/7 行表达,这里只保留一条
/// 记录以维持 payload 形状,取列表首条歌曲 ID。
Map<String, dynamic> _buildMusicData() {
final musicId = _isUnlock && _pendingMusics.isNotEmpty
? _pendingMusics.first.musicId
: UserAllPayloadBuilder.placeholderMusicId;
return UserAllPayloadBuilder.placeholderMusicData(musicId: musicId);
}
/// 汇总成 wire 上的 userItemList 行。
List<Map<String, dynamic>> _buildUserItemList() {
if (_isUnlock) {
return [
for (final music in _pendingMusics)
for (final kind in music.itemKinds)
{
'itemKind': kind,
'itemId': music.musicId,
'stock': 1,
'isValid': true,
},
];
}
return [
for (final item in _pendingItems)
{
'itemKind': item.kind,
'itemId': item.id,
'stock': 1,
'isValid': true,
},
];
}
String? _validateInputs() {
if (_isUnlock) {
if (_pendingMusics.isEmpty) return AppStrings.unlockNeedList;
return null;
}
if (_pendingItems.isEmpty) return AppStrings.collectiblesNeedList;
return null;
}
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() {
_fetching = true;
_fetchError = null;
_userAllData = null;
});
try {
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final data = await service.fetchUserAllData(widget.userId);
if (!mounted) return;
setState(() {
_userAllData = data;
_fetching = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_fetchError = e.toString();
_fetching = false;
});
}
}
Future<void> _run() async {
final validationError = _validateInputs();
if (validationError != null) {
_snack(validationError);
return;
}
if (!TitleServerConfigHolder().isConfigured) {
_snack(AppStrings.ticketNotConfigured);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
_snack(_notLoggedInMessage);
return;
}
if (cooldownRemaining > 0) {
_snack(_cooldownNotice(cooldownRemaining));
return;
}
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
setState(() {
_running = true;
_error = null;
});
try {
Map<String, Map<String, dynamic>> data = _userAllData ?? const {};
if (data.isEmpty) {
_updateStep(RiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data);
}
_updateStep(RiskStep.upload);
final musicData = _buildMusicData();
final builder = UserAllPayloadBuilder(service.config);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
loginDateTime: loginDateTime,
musicData: musicData,
generalUserInfo: data,
);
final items = _buildUserItemList();
// isNewItemList 得按 (itemKind, itemId) 主键比对服务器快照来给 0/1,
// 不能一律发 '1':重复解锁一首已经存在的歌时,服务器会把插入冲突的
// 行整行丢掉,表现就是「上传成功但没生效」。
final existingKeys = await service.getUserItemKeys(
widget.userId,
items.map((e) => e['itemKind'] as int),
);
builder.applyItemListPatch(
packet,
items: items,
newFlags: [
for (final item in items)
existingKeys.contains(
'${item['itemKind']}:${item['itemId']}',
)
? '0'
: '1',
].join(),
);
if (_isUnlock) {
builder.applyMusicDetailPatch(packet, musicData: musicData);
}
await service.upsertUserAll(packet, widget.userId);
_updateStep(
RiskStep.complete,
_isUnlock
? await _verifyUnlock(service, items)
: AppStrings.collectiblesSuccess,
);
if (_autoLogout && widget.onExitToTitle != null) {
// 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(RiskStep.failed, e.message);
setState(() => _error = e.message);
} catch (e) {
_updateStep(RiskStep.failed, e.toString());
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _running = false);
}
}
/// 上传后回读 GetUserItemApi,把「服务器根本没存这行道具」和「存了但游戏里
/// 还是没有这首歌」分开——后者是曲目池门禁(机台 Music.xml 没这个 ID、
/// `eventName` 指向的活动不在开放窗口内、或落在 NG 名单里),
/// 见 NotesListManager.cs:70,**任何用户数据包都改不动它**。
Future<String> _verifyUnlock(
TitleApiService service,
List<Map<String, dynamic>> items,
) async {
final requested = [
for (final item in items) '${item['itemKind']}:${item['itemId']}',
];
Set<String> saved;
try {
saved = await service.getUserItemKeys(
widget.userId,
items.map((e) => e['itemKind'] as int),
);
} catch (e) {
return AppStrings.unlockVerifyFailed('$e');
}
final missing = requested.where((k) => !saved.contains(k)).toList();
if (missing.isNotEmpty) {
return AppStrings.unlockNotSaved(missing.join(' '));
}
return AppStrings.unlockVerified(requested.length);
}
String get _notLoggedInMessage => _isUnlock
? AppStrings.unlockNotLoggedIn
: AppStrings.collectiblesNotLoggedIn;
String _cooldownNotice(int remaining) => _isUnlock
? AppStrings.unlockCooldownNotice(remaining)
: AppStrings.collectiblesCooldownNotice(remaining);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (!TitleServerConfigHolder().isConfigured) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings_ethernet,
size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(AppStrings.ticketNotConfigured,
style: theme.textTheme.titleMedium),
],
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
children: [
if (!isLoggedIn)
AppNotice(
_notLoggedInMessage,
error: true,
icon: Icons.warning_amber_rounded,
),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
_cooldownNotice(cooldownRemaining),
icon: Icons.timer_outlined,
),
_buildDescCard(theme),
const SizedBox(height: 12),
if (_isUnlock)
_buildUnlockCard(theme)
else
_buildCollectiblesCard(theme),
const SizedBox(height: 12),
_buildFetchCard(theme),
const SizedBox(height: 12),
AutoLogoutToggle(
value: _autoLogout,
enabled: widget.onExitToTitle != null && !_running,
onChanged: (v) => setState(() => _autoLogout = v),
),
const SizedBox(height: 12),
_buildRunButton(theme),
if (_step != RiskStep.idle) ...[
const SizedBox(height: 16),
StepProgressCard(
step: _step,
message: _stepMessage,
error: _error,
steps: [
(RiskStep.fetchData, AppStrings.unlockStepFetch),
(
RiskStep.upload,
_isUnlock
? AppStrings.unlockStepUpload
: AppStrings.collectiblesStepUpload,
),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
),
),
);
}
Widget _buildDescCard(ThemeData theme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_isUnlock ? Icons.lock_open : Icons.card_giftcard,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.featureTitle,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
widget.featureDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Widget _buildUnlockCard(ThemeData theme) {
final enabled = !_running;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _unlockMusicIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.unlockMusicIdLabel,
hintText: AppStrings.unlockMusicIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
const SizedBox(height: 6),
_buildUnlockCheckbox(
theme,
value: _unlockMusic,
label: AppStrings.unlockMusicOption,
enabled: enabled,
onChanged: (v) => setState(() => _unlockMusic = v),
),
_buildUnlockCheckbox(
theme,
value: _unlockMaster,
label: AppStrings.unlockMasterOption,
enabled: enabled,
onChanged: (v) => setState(() => _unlockMaster = v),
),
_buildUnlockCheckbox(
theme,
value: _unlockRemaster,
label: AppStrings.unlockRemasterOption,
enabled: enabled,
onChanged: (v) => setState(() => _unlockRemaster = v),
),
const SizedBox(height: 8),
_buildAddButton(theme, onPressed: enabled ? _addMusicToList : null),
_buildPendingList(
theme,
title: AppStrings.unlockPendingTitle,
emptyHint: AppStrings.unlockPendingEmpty,
count: _pendingMusics.length,
rows: [
for (var i = 0; i < _pendingMusics.length; i++)
_buildPendingRow(
theme,
leading: '#${_pendingMusics[i].musicId}',
trailing: _pendingMusics[i].optionLabel,
onRemove: () => setState(() => _pendingMusics.removeAt(i)),
),
],
),
// DX 段的 Master / Re:Master 不白送,勾选项里漏一个就开不出来,
// 这是这段 ID 最容易踩的坑,只在列表真含 DX 曲时才说明。
if (_pendingMusics.any(_isDeluxeRange)) ...[
const SizedBox(height: 10),
Text(
AppStrings.unlockDxRangeNotice,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
height: 1.5,
),
),
],
],
),
),
);
}
Widget _buildAddButton(ThemeData theme, {VoidCallback? onPressed}) {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.add, size: 18),
label: const Text(AppStrings.listAddButton),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
);
}
Widget _buildPendingList(
ThemeData theme, {
required String title,
required String emptyHint,
required int count,
required List<Widget> rows,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 14),
Text(
'$title ($count)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
if (count == 0)
Text(
emptyHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
else
...rows,
],
);
}
Widget _buildPendingRow(
ThemeData theme, {
required String leading,
required String trailing,
required VoidCallback onRemove,
}) {
return Row(
children: [
Expanded(
child: Text(
'$leading $trailing',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
tooltip: AppStrings.listRemoveTooltip,
onPressed: _running ? null : onRemove,
color: theme.colorScheme.onSurfaceVariant,
visualDensity: VisualDensity.compact,
),
],
);
}
Widget _buildUnlockCheckbox(
ThemeData theme, {
required bool value,
required String label,
required bool enabled,
required ValueChanged<bool> onChanged,
}) {
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => onChanged(!value) : null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
children: [
Checkbox(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
),
],
),
),
);
}
Widget _buildCollectiblesCard(ThemeData theme) {
final enabled = !_running;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDropdown(
value: _itemKind,
label: AppStrings.collectiblesItemKindLabel,
items: [
for (final kind in AppStrings.collectiblesItemKinds)
DropdownMenuItem(
value: kind,
child: Text(
'${AppStrings.collectiblesItemKindName(kind)} ($kind)',
),
),
],
onChanged: (v) => setState(() => _itemKind = v),
),
const SizedBox(height: 12),
TextField(
controller: _itemIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.collectiblesItemIdLabel,
hintText: AppStrings.collectiblesItemIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
const SizedBox(height: 8),
_buildAddButton(theme, onPressed: enabled ? _addItemToList : null),
_buildPendingList(
theme,
title: AppStrings.collectiblesPendingTitle,
emptyHint: AppStrings.collectiblesPendingEmpty,
count: _pendingItems.length,
rows: [
for (var i = 0; i < _pendingItems.length; i++)
_buildPendingRow(
theme,
leading:
'${AppStrings.collectiblesItemKindName(_pendingItems[i].kind)} (${_pendingItems[i].kind})',
trailing: '#${_pendingItems[i].id}',
onRemove: () => setState(() => _pendingItems.removeAt(i)),
),
],
),
],
),
),
);
}
Widget _buildFetchCard(ThemeData theme) {
final hasData = _userAllData != null;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: hasData
? Colors.green.withValues(alpha: 0.5)
: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
hasData ? Icons.check_circle : Icons.download,
size: 18,
color: hasData ? Colors.green : theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasData
? AppStrings.unlockFetched
: AppStrings.unlockNoData,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _fetching || _running ? null : _fetchData,
icon: _fetching
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_fetching
? AppStrings.unlockFetching
: hasData
? AppStrings.unlockRefetch
: AppStrings.unlockFetchData,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
if (_fetchError != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_fetchError!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontFamily: 'monospace',
),
),
),
],
],
),
),
);
}
Widget _buildRunButton(ThemeData theme) {
final onCooldown = cooldownRemaining > 0;
final canRun = widget.loginDateTime != null &&
widget.loginId != null &&
!_running &&
!onCooldown;
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: canRun ? _run : null,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Icon(
onCooldown
? Icons.timer_outlined
: (_isUnlock ? Icons.lock_open : Icons.card_giftcard),
size: 20,
),
label: Text(
_running
? AppStrings.unlockRunning
: onCooldown
? AppStrings.ticketCooldownCountdown(cooldownRemaining)
: _isUnlock
? AppStrings.unlockMusicRun
: AppStrings.collectiblesRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
Widget _buildDropdown({
required int value,
required String label,
required List<DropdownMenuItem<int>> items,
required ValueChanged<int> onChanged,
}) {
return DropdownButtonFormField<int>(
initialValue: value,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
items: items,
onChanged: _running
? null
: (v) {
if (v == null) return;
onChanged(v);
},
);
}
}