feat: 旅行伙伴发放与编组、修复退登卡死、优化宽屏布局、加 CI
自动退出登录卡死:_performLogoutAndExit 只 pop() 一层,而解锁/收藏品页是 push
在 HomePage 之上的,退登后被弹回「风险」hub,同时 _loggingOut 仍为 true,
PopScope(canPop:false) + 返回键转圈形成死局。改为 popUntil(isFirst)。
旅行伙伴发不了:它是 ItemKind.Character = 9,与搭档 Partner = 10 是两种东西,
原先收藏品的类型列表 [1,2,3,10,11,12] 里根本没有 9。按客户端实现,角色不走
userItemList(ExportUserItems 不导出 itemKind 9),而是
upsertUserAll.userCharacterList 的 {characterId, level, awakening, useCount},
配 isNewCharacterList 一行一字符。新增发放与 5 槽 charaSlot 编组页,
含「把槽 0 复制到其他槽位」,并校验所选角色确为已拥有或本次待发放。
宽屏布局:各页只有 ConstrainedBox 限宽、没有居中容器,1440 窗口下内容贴左、
右侧大片空白。抽出 responsiveBody 统一居中;B50 预览改用按宽度适配
(InteractiveViewer constrained:false + FittedBox fitWidth),横屏下不再被
contain 缩成中间一小块,导出仍是原始 1762x1850。
CI:.github/workflows/build.yml 先跑 analyze+test 作为门槛,再产出
Windows exe、Android apk、iOS 未签名 ipa(macOS runner,无证书故不可安装)。
README 从 Flutter 模板改写为实际文档,含 path_provider/jni 与 JBR jlink
两个构建环境坑的说明。
This commit is contained in:
1 parent
ebd1fdddaa
commit
8dda399f76
24 files changed
+1832
-134
No files matched your search
@@ -0,0 +1,766 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/responsive.dart';
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../models/user_character.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import '../services/user_all_payload_builder.dart';
|
||||
|
||||
/// 旅行伙伴页:发放角色 + 编组出战槽位。
|
||||
///
|
||||
/// 依据反编译客户端:
|
||||
/// - 旅行伙伴是 `ItemKind.Character = 9`,与「搭档」`Partner = 10` 是两种东西。
|
||||
/// - 客户端的 `ExportUserItems()` 不导出 itemKind 9;角色的拥有状态走
|
||||
/// `upsertUserAll.userCharacterList`(`{characterId, level, awakening, useCount}`,
|
||||
/// 主键 characterId,见 VOExtensions.cs:265)。
|
||||
/// - 出战编组是 `UserDetail.charaSlot`,`int[5]`,槽 0 为队长
|
||||
/// (CharactorSlotController.cs:27)。
|
||||
class TravelPartnerPage extends StatefulWidget {
|
||||
final int userId;
|
||||
final String? cookies;
|
||||
final int? loginDateTime;
|
||||
final int? loginId;
|
||||
|
||||
/// 完成后退出登录并返回标题页,由 HomePage 提供。
|
||||
final Future<void> Function()? onExitToTitle;
|
||||
|
||||
const TravelPartnerPage({
|
||||
super.key,
|
||||
required this.userId,
|
||||
this.cookies,
|
||||
this.loginDateTime,
|
||||
this.loginId,
|
||||
this.onExitToTitle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TravelPartnerPage> createState() => _TravelPartnerPageState();
|
||||
}
|
||||
|
||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
||||
|
||||
class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
||||
/// 与解锁/收藏品一致:这些操作不写真实成绩,playlog 沿用同一条占位记录。
|
||||
static const int _placeholderMusicId = 11538;
|
||||
|
||||
final _grantIdController = TextEditingController();
|
||||
|
||||
List<UserCharacterBean> _owned = const [];
|
||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||
final List<int> _pendingGrant = [];
|
||||
List<int> _slots = normalizeCharaSlot(const []);
|
||||
|
||||
bool _loading = false;
|
||||
bool _running = false;
|
||||
bool _autoLogout = true;
|
||||
_Step _step = _Step.idle;
|
||||
String _stepMessage = '';
|
||||
|
||||
Timer? _cooldownTimer;
|
||||
int _cooldownRemaining = 0;
|
||||
|
||||
bool get _loggedIn => widget.loginDateTime != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncCooldown();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TravelPartnerPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
||||
_syncCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cooldownTimer?.cancel();
|
||||
_grantIdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncCooldown() {
|
||||
_cooldownTimer?.cancel();
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
if (loginDateTime == null) {
|
||||
setState(() => _cooldownRemaining = 0);
|
||||
return;
|
||||
}
|
||||
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
|
||||
if (_cooldownRemaining <= 0) return;
|
||||
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
final remaining = _computeRemaining(loginDateTime);
|
||||
setState(() => _cooldownRemaining = remaining);
|
||||
if (remaining <= 0) {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int _computeRemaining(int loginDateTime) {
|
||||
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final remaining =
|
||||
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
||||
return remaining < 0 ? 0 : remaining;
|
||||
}
|
||||
|
||||
void _snack(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isAvailable(int characterId) =>
|
||||
characterId != 0 &&
|
||||
(_owned.any((c) => c.characterId == characterId) ||
|
||||
_pendingGrant.contains(characterId));
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final results = await Future.wait([
|
||||
service.getUserCharacters(widget.userId),
|
||||
service.fetchUserAllData(widget.userId),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
|
||||
final owned = results[0] as List<UserCharacterBean>;
|
||||
final all = results[1] as Map<String, Map<String, dynamic>>;
|
||||
final userData =
|
||||
(all['GetUserDataApi']?['userData'] as Map<String, dynamic>?) ??
|
||||
const {};
|
||||
final slots = (userData['charaSlot'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e is int ? e : int.tryParse('$e') ?? 0)
|
||||
.toList();
|
||||
|
||||
setState(() {
|
||||
_owned = owned;
|
||||
_userAllData = all;
|
||||
_slots = normalizeCharaSlot(slots);
|
||||
// 槽位里引用了但列表没有的角色,保留为可选,避免下拉框丢项。
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_snack('${AppStrings.loadFailed}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _addGrant() {
|
||||
final id = int.tryParse(_grantIdController.text.trim()) ?? -1;
|
||||
if (id <= 0) {
|
||||
_snack(AppStrings.travelPartnerNeedCharacterId);
|
||||
return;
|
||||
}
|
||||
if (_owned.any((c) => c.characterId == id)) {
|
||||
_snack(AppStrings.travelPartnerAlreadyOwned);
|
||||
return;
|
||||
}
|
||||
if (_pendingGrant.contains(id)) {
|
||||
_snack(AppStrings.listDuplicate);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_pendingGrant.add(id);
|
||||
_grantIdController.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/// 一键把槽 0 的旅行伙伴复制到其他四个槽位。
|
||||
void _copySlot0ToOthers() {
|
||||
final leader = _slots.isNotEmpty ? _slots[0] : 0;
|
||||
if (leader == 0) {
|
||||
_snack(AppStrings.travelPartnerCopyNeedSlot0);
|
||||
return;
|
||||
}
|
||||
if (!_isAvailable(leader)) {
|
||||
_snack(AppStrings.travelPartnerNotOwnedHint);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_slots = normalizeCharaSlot([for (var i = 0; i < 5; i++) leader]);
|
||||
});
|
||||
}
|
||||
|
||||
String? _validate() {
|
||||
if (_pendingGrant.isEmpty && _userAllData.isEmpty) {
|
||||
return AppStrings.travelPartnerNoData;
|
||||
}
|
||||
if (_userAllData.isEmpty) return AppStrings.travelPartnerNoData;
|
||||
for (final slot in _slots) {
|
||||
if (slot != 0 && !_isAvailable(slot)) {
|
||||
return AppStrings.travelPartnerNotOwnedHint;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
_snack(AppStrings.ticketNotConfigured);
|
||||
return;
|
||||
}
|
||||
final loginDateTime = widget.loginDateTime;
|
||||
final loginId = widget.loginId;
|
||||
if (loginDateTime == null || loginId == null) {
|
||||
_snack(AppStrings.travelPartnerNotLoggedIn);
|
||||
return;
|
||||
}
|
||||
if (_cooldownRemaining > 0) {
|
||||
_snack(AppStrings.travelPartnerCooldownNotice(_cooldownRemaining));
|
||||
return;
|
||||
}
|
||||
final validationError = _validate();
|
||||
if (validationError != null) {
|
||||
_snack(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _running = true);
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config, cookies: widget.cookies);
|
||||
final builder = UserAllPayloadBuilder(config);
|
||||
|
||||
var data = _userAllData;
|
||||
if (data.isEmpty) {
|
||||
_updateStep(_Step.fetchData);
|
||||
data = await service.fetchUserAllData(widget.userId);
|
||||
if (mounted) setState(() => _userAllData = data);
|
||||
}
|
||||
|
||||
_updateStep(_Step.upload);
|
||||
final packet = builder.build(
|
||||
userId: widget.userId,
|
||||
loginId: loginId,
|
||||
loginDateTime: loginDateTime,
|
||||
musicData: _placeholderMusicData(),
|
||||
generalUserInfo: data,
|
||||
);
|
||||
|
||||
if (_pendingGrant.isNotEmpty) {
|
||||
// 新角色给最小可用值;已拥有的不重发,避免把 level/awakening 打回。
|
||||
builder.applyCharacterListPatch(
|
||||
packet,
|
||||
characters: [
|
||||
for (final id in _pendingGrant)
|
||||
{'characterId': id, 'level': 1, 'awakening': 0, 'useCount': 0},
|
||||
],
|
||||
);
|
||||
}
|
||||
builder.applyCharaSlotPatch(packet, charaSlot: _slots);
|
||||
|
||||
await service.upsertUserAll(packet, widget.userId);
|
||||
|
||||
_updateStep(_Step.complete, AppStrings.travelPartnerSuccess);
|
||||
|
||||
if (_autoLogout && widget.onExitToTitle != null) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
||||
await widget.onExitToTitle!();
|
||||
}
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(_Step.failed, e.message);
|
||||
} catch (e) {
|
||||
_updateStep(_Step.failed, '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _placeholderMusicData() => {
|
||||
'musicId': _placeholderMusicId,
|
||||
'level': 0,
|
||||
'playCount': 1,
|
||||
'achievement': 0,
|
||||
'comboStatus': 0,
|
||||
'syncStatus': 0,
|
||||
'deluxscoreMax': 0,
|
||||
'scoreRank': 0,
|
||||
'extNum1': 0,
|
||||
};
|
||||
|
||||
void _updateStep(_Step step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.travelPartnerFeatureTitle),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: responsiveBody(
|
||||
context,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!_loggedIn)
|
||||
_notice(
|
||||
theme,
|
||||
AppStrings.travelPartnerNotLoggedIn,
|
||||
error: true,
|
||||
),
|
||||
if (_loggedIn && _cooldownRemaining > 0)
|
||||
_notice(
|
||||
theme,
|
||||
AppStrings.travelPartnerCooldownNotice(_cooldownRemaining),
|
||||
),
|
||||
_sectionCard(
|
||||
theme,
|
||||
icon: Icons.card_giftcard,
|
||||
title: AppStrings.travelPartnerFeatureDesc,
|
||||
children: const [],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_fetchCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_grantCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_slotCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_ownedCard(theme),
|
||||
const SizedBox(height: 12),
|
||||
_autoLogoutToggle(theme),
|
||||
const SizedBox(height: 12),
|
||||
_runButton(theme),
|
||||
if (_step != _Step.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_progressCard(theme),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: error
|
||||
? theme.colorScheme.errorContainer
|
||||
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: error
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionCard(
|
||||
ThemeData theme, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required List<Widget> children,
|
||||
}) {
|
||||
return _card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(ThemeData theme, {required Widget child}) {
|
||||
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: child),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fetchCard(ThemeData theme) {
|
||||
final hasData = _userAllData.isNotEmpty && _owned.isNotEmpty;
|
||||
return _card(
|
||||
theme,
|
||||
child: 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.travelPartnerFetched
|
||||
: AppStrings.travelPartnerNoData,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: hasData ? Colors.green : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading || _running ? null : _fetchData,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
_loading
|
||||
? AppStrings.travelPartnerFetching
|
||||
: _userAllData.isNotEmpty
|
||||
? AppStrings.travelPartnerRefetch
|
||||
: AppStrings.travelPartnerFetchData,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _grantCard(ThemeData theme) {
|
||||
final enabled = !_running;
|
||||
return _sectionCard(
|
||||
theme,
|
||||
icon: Icons.add_circle_outline,
|
||||
title: AppStrings.travelPartnerGrantTitle,
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _grantIdController,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppStrings.travelPartnerCharacterIdLabel,
|
||||
hintText: AppStrings.travelPartnerCharacterIdHint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: enabled ? _addGrant : null,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_pendingGrant.isEmpty
|
||||
? AppStrings.travelPartnerGrantEmpty
|
||||
: _pendingGrant.map((e) => '#$e').join(' '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: _pendingGrant.isEmpty
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _slotCard(ThemeData theme) {
|
||||
// 下拉选项 = 已拥有 ∪ 待发放 ∪ 当前槽位已有值。
|
||||
final optionIds = <int>{
|
||||
..._owned.map((c) => c.characterId),
|
||||
..._pendingGrant,
|
||||
..._slots.where((e) => e != 0),
|
||||
}.toList()
|
||||
..sort();
|
||||
|
||||
return _sectionCard(
|
||||
theme,
|
||||
icon: Icons.groups_outlined,
|
||||
title: AppStrings.travelPartnerSlotTitle,
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
for (var i = 0; i < 5; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: DropdownButtonFormField<int>(
|
||||
initialValue: _slots[i],
|
||||
decoration: InputDecoration(
|
||||
labelText: AppStrings.travelPartnerSlotLabel(i),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 0,
|
||||
child: Text(
|
||||
AppStrings.travelPartnerSlotNone,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
for (final id in optionIds)
|
||||
DropdownMenuItem(value: id, child: Text('#$id')),
|
||||
],
|
||||
onChanged: _running
|
||||
? null
|
||||
: (v) => setState(() {
|
||||
final next = [..._slots];
|
||||
next[i] = v ?? 0;
|
||||
_slots = next;
|
||||
}),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _running ? null : _copySlot0ToOthers,
|
||||
icon: const Icon(Icons.copy_all_outlined, size: 18),
|
||||
label: const Text(AppStrings.travelPartnerCopySlot0),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ownedCard(ThemeData theme) {
|
||||
return _sectionCard(
|
||||
theme,
|
||||
icon: Icons.workspace_premium_outlined,
|
||||
title:
|
||||
'${AppStrings.travelPartnerOwnedTitle} (${_owned.length})',
|
||||
children: [
|
||||
const SizedBox(height: 14),
|
||||
if (_owned.isEmpty)
|
||||
Text(
|
||||
AppStrings.travelPartnerOwnedEmpty,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final c in _owned)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'#${c.characterId} Lv${c.level}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _autoLogoutToggle(ThemeData theme) {
|
||||
final enabled = widget.onExitToTitle != null && !_running;
|
||||
return _card(
|
||||
theme,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _autoLogout,
|
||||
onChanged: enabled
|
||||
? (v) => setState(() => _autoLogout = v ?? false)
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.autoLogoutAndExit,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runButton(ThemeData theme) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running || !_loggedIn ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send, size: 20),
|
||||
label: Text(
|
||||
_running ? AppStrings.travelPartnerRunning : AppStrings.travelPartnerRun,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _progressCard(ThemeData theme) {
|
||||
final isFailed = _step == _Step.failed;
|
||||
return _card(
|
||||
theme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_stepRow(theme, _Step.fetchData, AppStrings.travelPartnerStepFetch),
|
||||
_stepRow(theme, _Step.upload, AppStrings.travelPartnerStepUpload),
|
||||
if (_autoLogout)
|
||||
_stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
||||
if (_stepMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: isFailed
|
||||
? theme.colorScheme.errorContainer
|
||||
: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_stepMessage,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: isFailed
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
||||
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
||||
final current = _step == _Step.failed ? _Step.upload : _step;
|
||||
final done = order.indexOf(current) > order.indexOf(step);
|
||||
final active = _step == step;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
done
|
||||
? Icons.check_circle
|
||||
: active
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: done
|
||||
? Colors.green
|
||||
: active
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user