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:
Cookiues committed 2026-09-26 00:37:54 +08:00
1 parent ebd1fdddaa
commit 8dda399f76
24 files changed
+1832 -134

No files matched your search

+2 -1
View File
@@ -128,8 +128,9 @@ void main() {
children: [
Expanded(
child: InteractiveViewer(
constrained: false,
child: FittedBox(
fit: BoxFit.contain,
fit: BoxFit.fitWidth,
child: Best50Poster(
userName: 'Test Player',
iconId: 0,
+120
View File
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/pages/music_risk_feature_view.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> _seedConfig() async {
SharedPreferences.setMockInitialValues({});
await TitleServerConfigHolder().update(
const TitleServerConfig(
titleServerUrl: 'http://127.0.0.1:9999',
aesKey: '0123456789abcdef',
aesIv: '0123456789abcdef',
clientId: 'A63E01C2805',
),
);
}
Future<void> _pumpView(WidgetTester tester, MusicRiskFeatureMode mode) async {
await _seedConfig();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MusicRiskFeatureView(
userId: 123,
cookies: 'JSESSIONID=X',
loginDateTime: 1,
loginId: 2,
mode: mode,
featureTitle: 't',
featureDesc: 'd',
onExitToTitle: () async {},
),
),
),
);
await tester.pump();
}
Future<void> _addById(WidgetTester tester, String label, String id) async {
await tester.enterText(find.widgetWithText(TextField, label), id);
await tester.tap(find.text(AppStrings.listAddButton).first);
await tester.pump();
}
void main() {
setUp(() => addTearDown(TitleServerConfigHolder().clear));
testWidgets('收藏品列表可累加多行并可移除', (tester) async {
await _pumpView(tester, MusicRiskFeatureMode.collectibles);
expect(find.textContaining(AppStrings.collectiblesPendingEmpty), findsOne);
await _addById(tester, AppStrings.collectiblesItemIdLabel, '250103');
await _addById(tester, AppStrings.collectiblesItemIdLabel, '30021');
expect(find.textContaining('#250103'), findsOneWidget);
expect(find.textContaining('#30021'), findsOneWidget);
expect(
find.textContaining('${AppStrings.collectiblesPendingTitle} (2)'),
findsOneWidget,
);
// 同一类型同一 ID 不应重复入列。
await _addById(tester, AppStrings.collectiblesItemIdLabel, '30021');
expect(
find.textContaining('${AppStrings.collectiblesPendingTitle} (2)'),
findsOneWidget,
);
await tester.tap(find.byTooltip(AppStrings.listRemoveTooltip).first);
await tester.pump();
expect(find.textContaining('#250103'), findsNothing);
expect(
find.textContaining('${AppStrings.collectiblesPendingTitle} (1)'),
findsOneWidget,
);
});
testWidgets('解锁列表按勾选展开难度且添加后清空输入', (tester) async {
await _pumpView(tester, MusicRiskFeatureMode.unlockMusic);
await tester.enterText(
find.widgetWithText(TextField, AppStrings.unlockMusicIdLabel),
'834',
);
await tester.tap(find.text(AppStrings.unlockMusicOption).first);
await tester.tap(find.text(AppStrings.unlockMasterOption).first);
await tester.pump();
await tester.tap(find.text(AppStrings.listAddButton).first);
await tester.pump();
final row = find.textContaining('#834');
expect(row, findsOneWidget);
expect(tester.widget<Text>(row.first).data, contains('Master'));
// 添加后输入与勾选应被清空,方便连续添加下一条。
expect(
tester.widget<TextField>(
find.widgetWithText(TextField, AppStrings.unlockMusicIdLabel),
).controller!.text,
isEmpty,
);
});
testWidgets('自动退出登录并返回标题页默认勾选', (tester) async {
await _pumpView(tester, MusicRiskFeatureMode.collectibles);
expect(
find.text(AppStrings.autoLogoutAndExit),
findsOneWidget,
);
// 未勾选任何解锁项时,唯一处于选中态的复选框就是自动退登。
final checked = tester
.widgetList<Checkbox>(find.byType(Checkbox))
.where((c) => c.value == true)
.toList();
expect(checked, hasLength(1));
});
}
+77
View File
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/pages/travel_partner_page.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> _pumpPage(WidgetTester tester, {required bool loggedIn}) async {
SharedPreferences.setMockInitialValues({});
await TitleServerConfigHolder().update(
const TitleServerConfig(
titleServerUrl: 'http://127.0.0.1:9999',
aesKey: '0123456789abcdef',
aesIv: '0123456789abcdef',
clientId: 'A63E01C2805',
),
);
tester.view.physicalSize = loggedIn ? const Size(420, 1500) : const Size(1440, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: TravelPartnerPage(
userId: 42,
cookies: 'JSESSIONID=X',
loginDateTime: loggedIn ? 1 : null,
loginId: loggedIn ? 7 : null,
onExitToTitle: () async {},
),
),
);
await tester.pump();
}
void main() {
testWidgets('已登录时渲染 5 个槽位与一键复制,且无溢出', (tester) async {
await _pumpPage(tester, loggedIn: true);
expect(tester.takeException(), isNull);
for (var i = 0; i < 5; i++) {
expect(
find.text(AppStrings.travelPartnerSlotLabel(i)),
findsOneWidget,
reason: '缺少槽位 $i 的下拉框',
);
}
expect(find.text(AppStrings.travelPartnerCopySlot0), findsOneWidget);
expect(find.text(AppStrings.travelPartnerGrantTitle), findsOneWidget);
});
testWidgets('未登录时给出提示并禁用执行', (tester) async {
await _pumpPage(tester, loggedIn: false);
expect(tester.takeException(), isNull);
expect(find.text(AppStrings.travelPartnerNotLoggedIn), findsOneWidget);
final run = tester.widget<FilledButton>(
find.ancestor(
of: find.text(AppStrings.travelPartnerRun),
matching: find.byType(FilledButton),
),
);
expect(run.onPressed, isNull);
});
testWidgets('槽 0 未选择时复制按钮不改动槽位', (tester) async {
await _pumpPage(tester, loggedIn: true);
await tester.tap(find.text(AppStrings.travelPartnerCopySlot0));
await tester.pump();
// 未拥有任何角色时,复制应被拦下并提示,而不是写入非法槽位。
expect(find.text(AppStrings.travelPartnerCopyNeedSlot0), findsOneWidget);
});
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/models/user_character.dart';
import 'package:ichino/services/user_all_payload_builder.dart';
const _config = TitleServerConfig(
titleServerUrl: 'http://127.0.0.1:9999',
aesKey: '0123456789abcdef',
aesIv: '0123456789abcdef',
clientId: 'A63E01C2805',
);
Map<String, dynamic> _buildPacket(UserAllPayloadBuilder builder) =>
builder.build(
userId: 42,
loginId: 7,
loginDateTime: 1700000000,
musicData: const {
'musicId': 11538,
'level': 0,
'playCount': 1,
'achievement': 0,
'comboStatus': 0,
'syncStatus': 0,
'deluxscoreMax': 0,
'scoreRank': 0,
'extNum1': 0,
},
generalUserInfo: const {},
);
Map<String, dynamic> _upsert(Map<String, dynamic> packet) =>
packet['upsertUserAll'] as Map<String, dynamic>;
void main() {
group('旅行伙伴 userCharacterList patch', () {
test('写入 4 字段行并按行数生成 isNewCharacterList', () {
final builder = UserAllPayloadBuilder(_config);
final packet = _buildPacket(builder);
builder.applyCharacterListPatch(packet, characters: [
{'characterId': 1001, 'level': 1, 'awakening': 0, 'useCount': 0},
{'characterId': 1002, 'level': 3, 'awakening': 1, 'useCount': 9},
]);
final upsert = _upsert(packet);
expect(upsert['userCharacterList'], hasLength(2));
expect(upsert['isNewCharacterList'], '11');
final first = (upsert['userCharacterList'] as List).first
as Map<String, dynamic>;
// 客户端 UserCharacter 结构只有这 4 个字段,多一个都可能被拒。
expect(first.keys.toSet(), {
'characterId',
'level',
'awakening',
'useCount',
});
});
test('空列表时 isNewCharacterList 为空串', () {
final builder = UserAllPayloadBuilder(_config);
final packet = _buildPacket(builder);
builder.applyCharacterListPatch(packet, characters: []);
expect(_upsert(packet)['isNewCharacterList'], '');
});
});
group('charaSlot 编组 patch', () {
test('槽位补齐到 5 并同步 playlog 的 characterId1..5', () {
final builder = UserAllPayloadBuilder(_config);
final packet = _buildPacket(builder);
builder.applyCharaSlotPatch(packet, charaSlot: const [1001]);
final userData = (_upsert(packet)['userData'] as List).first
as Map<String, dynamic>;
expect(userData['charaSlot'], [1001, 0, 0, 0, 0]);
final playlog = (packet['userPlaylogList'] as List).first
as Map<String, dynamic>;
expect(playlog['characterId1'], 1001);
expect(playlog['characterId2'], 0);
});
test('超过 5 个槽位被截断', () {
expect(
normalizeCharaSlot(const [1, 2, 3, 4, 5, 6, 7]),
[1, 2, 3, 4, 5],
);
});
test('一键复制槽 0 到其余槽位', () {
final slots = normalizeCharaSlot(const [1001, 0, 0, 0, 0]);
final copied = normalizeCharaSlot([
for (var i = 0; i < 5; i++) slots[0],
]);
expect(copied, [1001, 1001, 1001, 1001, 1001]);
});
});
group('收藏品 userItemList patch', () {
test('多行道具与 isNewItemList 位数一致', () {
final builder = UserAllPayloadBuilder(_config);
final packet = _buildPacket(builder);
builder.applyItemListPatch(packet, items: [
{'itemKind': 9, 'itemId': 1001, 'stock': 1, 'isValid': true},
{'itemKind': 10, 'itemId': 2002, 'stock': 1, 'isValid': true},
]);
final upsert = _upsert(packet);
expect(upsert['userItemList'], hasLength(2));
expect(upsert['isNewItemList'], '11');
});
});
}