import 'package:flutter/material.dart'; import '../config/strings.dart'; import '../models/session_model.dart'; import '../models/user_kaleidx_scope.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/risk/risk_run_mixin.dart'; import '../widgets/risk/risk_widgets.dart'; import '../widgets/step_progress.dart'; /// 万花筒专区:发现新的宿命之门、获取门的钥匙、设置门的通关状态。 /// /// 三个动作都落在 `upsertUserAll.userKaleidxScopeList` 同一行的三个布尔位上 /// (`isGateFound` / `isKeyFound` / `isClear`)。钥匙不走 `userItemList`: /// `ExportUserItems` 从不输出 itemKind 15,下行也没有读它的路径。 class KaleidxScopePage extends StatefulWidget { final int userId; final String? cookies; final int? loginDateTime; final int? loginId; final Future Function()? onExitToTitle; const KaleidxScopePage({ super.key, required this.userId, this.cookies, this.loginDateTime, this.loginId, this.onExitToTitle, }); @override State createState() => _KaleidxScopePageState(); } /// 通关态可以往两个方向改,所以用三态而不是又一个勾选框。 enum _ClearChoice { keep, cleared, uncleared } class _KaleidxScopePageState extends State with CooldownMixin, RiskRunMixin { final _gateIdController = TextEditingController(); final List _pending = []; bool _discover = true; bool _giveKey = false; _ClearChoice _clearChoice = _ClearChoice.keep; Map> _userAllData = const {}; List _serverScopes = const []; @override int? get cooldownLoginDateTime => widget.loginDateTime; @override void dispose() { _gateIdController.dispose(); super.dispose(); } void _snack(String message) => context.showSnack(message); void _addPending() { final id = int.tryParse(_gateIdController.text.trim()) ?? -1; if (id <= 0) { _snack(AppStrings.kaleidxNeedGateId); return; } if (_pending.contains(id)) { _snack(AppStrings.kaleidxGateIdDuplicated); return; } setState(() { _pending.add(id); _gateIdController.clear(); }); } UserKaleidxScopeBean? _serverRow(int gateId) { for (final row in _serverScopes) { if (row.gateId == gateId) return row; } return null; } bool? get _targetClear => switch (_clearChoice) { _ClearChoice.keep => null, _ClearChoice.cleared => true, _ClearChoice.uncleared => false, }; /// 整行替换,所以已存在的门必须拿服务器原行做底,只改要改的位。 List _buildRows() { final clear = _targetClear; final timestamp = UserAllPayloadBuilder.nowTimestamp(); return [ for (final id in _pending) _serverRow(id)?.withActions( discover: _discover, giveKey: _giveKey, clear: clear, timestamp: timestamp, ) ?? UserKaleidxScopeBean.discovered( id, giveKey: _giveKey, clear: clear, timestamp: timestamp, ), ]; } Future _fetchData() async { final service = SessionModel.instance.createTitleService( cookies: widget.cookies, ); if (service == null) return; setRiskFetching(true); try { final results = await Future.wait([ service.fetchUserAllData(widget.userId), service.getUserKaleidxScopes(widget.userId), ]); if (!mounted) return; setState(() { _userAllData = results[0] as Map>; _serverScopes = results[1] as List; riskFetching = false; }); } catch (e) { if (!mounted) return; setState(() => riskFetching = false); _snack('${AppStrings.loadFailed}: ${describeApiError(e)}'); } } Future _run() async { final guard = riskPreconditionError( loginDateTime: widget.loginDateTime, loginId: widget.loginId, cooldownRemaining: cooldownRemaining, notLoggedInMessage: AppStrings.kaleidxNotLoggedIn, cooldownNotice: AppStrings.kaleidxCooldownNotice, ); if (guard != null) { _snack(guard); return; } if (_pending.isEmpty) { _snack(AppStrings.kaleidxNeedGateId); return; } if (!_discover && !_giveKey && _clearChoice == _ClearChoice.keep) { _snack(AppStrings.kaleidxNeedAction); return; } final loginDateTime = widget.loginDateTime!; final loginId = widget.loginId!; setRiskRunning(true); try { final service = SessionModel.instance.createTitleService( cookies: widget.cookies, )!; final builder = UserAllPayloadBuilder(service.config); var data = _userAllData; if (data.isEmpty || _serverScopes.isEmpty) { updateRiskStep(RiskStep.fetchData); final results = await Future.wait([ service.fetchUserAllData(widget.userId), service.getUserKaleidxScopes(widget.userId), ]); data = results[0] as Map>; if (mounted) { setState(() { _userAllData = data; _serverScopes = results[1] as List; }); } } updateRiskStep(RiskStep.upload); final packet = builder.build( userId: widget.userId, loginId: loginId, loginDateTime: loginDateTime, musicData: UserAllPayloadBuilder.placeholderMusicData(), generalUserInfo: data, ); final rows = _buildRows(); // 每一位 '1' 插入 / '0' 更新,判据是服务器有没有同 gateId 的行。 final flags = [ for (final row in rows) _serverRow(row.gateId) == null ? '1' : '0', ].join(); builder.applyKaleidxScopePatch( packet, scopes: [for (final row in rows) row.toWireJson()], newFlags: flags, ); await service.upsertUserAll(packet, widget.userId); updateRiskStep(RiskStep.complete, await _verify(service)); await autoLogoutAndExit(widget.onExitToTitle); } catch (e) { reportRiskError(e); } finally { setRiskRunning(false); } } Future _verify(TitleApiService service) async { final requested = List.from(_pending); List rows; try { rows = await service.getUserKaleidxScopes(widget.userId); } catch (e) { return AppStrings.kaleidxVerifyFailed(describeApiError(e)); } if (mounted) setState(() => _serverScopes = rows); final saved = rows.where((r) => requested.contains(r.gateId)); final missing = requested .where((id) => !rows.any((r) => r.gateId == id)) .toList(); if (missing.isNotEmpty) { return AppStrings.kaleidxNotSaved(missing.join(' / ')); } return AppStrings.kaleidxVerified(saved.length); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: const Text(AppStrings.kaleidxFeatureTitle)), body: RiskPageBody( children: [ RiskStatusNotices( isLoggedIn: isLoggedIn, cooldownRemaining: cooldownRemaining, notLoggedInText: AppStrings.kaleidxNotLoggedIn, cooldownText: AppStrings.kaleidxCooldownNotice, ), RiskFeatureCard( icon: Icons.science_outlined, title: AppStrings.kaleidxFeatureTitle, description: AppStrings.kaleidxFeatureDesc, ), const SizedBox(height: 12), _inputCard(theme), const SizedBox(height: 12), RiskFetchCard( hasData: _userAllData.isNotEmpty, statusText: _userAllData.isNotEmpty ? '${AppStrings.kaleidxFetched} (${_serverScopes.length})' : AppStrings.kaleidxNoData, loadingLabel: AppStrings.travelPartnerFetching, actionLabel: AppStrings.kaleidxFetchData, loading: riskFetching, enabled: !riskRunning, onFetch: _fetchData, ), const SizedBox(height: 12), AutoLogoutToggle( value: riskAutoLogout, enabled: widget.onExitToTitle != null && !riskRunning, onChanged: setRiskAutoLogout, ), const SizedBox(height: 12), RiskRunButton( running: riskRunning, enabled: isLoggedIn, icon: Icons.lock_open_outlined, label: AppStrings.kaleidxRun, runningLabel: AppStrings.kaleidxRunning, onPressed: _run, ), if (riskStep != RiskStep.idle) ...[ const SizedBox(height: 16), StepProgressCard( step: riskStep, message: riskStepMessage, steps: [ (RiskStep.fetchData, AppStrings.kaleidxStepFetch), (RiskStep.upload, AppStrings.kaleidxStepUpload), if (riskAutoLogout) (RiskStep.logout, AppStrings.stepLogout), ], ), ], ], ), ); } Widget _inputCard(ThemeData theme) { final enabled = !riskRunning; return AppCard( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _gateIdController, enabled: enabled, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: AppStrings.kaleidxGateIdLabel, hintText: AppStrings.kaleidxGateIdHint, border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), ), contentPadding: const EdgeInsets.all(14), ), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: enabled ? _addPending : 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: 4), RiskCheckRow( label: AppStrings.kaleidxActionDiscover, value: _discover, enabled: enabled, onChanged: (v) => setState(() => _discover = v), ), RiskCheckRow( label: AppStrings.kaleidxActionKey, value: _giveKey, enabled: enabled, onChanged: (v) => setState(() => _giveKey = v), ), const SizedBox(height: 10), DropdownButtonFormField<_ClearChoice>( initialValue: _clearChoice, decoration: InputDecoration( labelText: AppStrings.kaleidxClearLabel, border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), ), contentPadding: const EdgeInsets.all(14), ), items: const [ DropdownMenuItem( value: _ClearChoice.keep, child: Text(AppStrings.kaleidxClearKeep), ), DropdownMenuItem( value: _ClearChoice.cleared, child: Text(AppStrings.kaleidxClearCleared), ), DropdownMenuItem( value: _ClearChoice.uncleared, child: Text(AppStrings.kaleidxClearUncleared), ), ], onChanged: enabled ? (v) => setState( () => _clearChoice = v ?? _ClearChoice.keep, ) : null, ), if (_giveKey || _clearChoice == _ClearChoice.cleared) ...[ const SizedBox(height: 6), Text( _giveKey ? AppStrings.kaleidxKeyNeedsGate : AppStrings.kaleidxClearNeedsKey, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, height: 1.5, ), ), ], const SizedBox(height: 10), for (final id in _pending) Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Text( '#$id ${_stateLabelFor(id)}', style: theme.textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), ), ), if (_pending.isEmpty) Text( AppStrings.kaleidxPendingEmpty, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ); } String _stateLabelFor(int gateId) { final row = _serverRow(gateId); if (row == null) { return _userAllData.isEmpty ? '' : AppStrings.kaleidxGateState(false, false, false); } return AppStrings.kaleidxGateState( row.isGateFound, row.isKeyFound, row.isClear, ); } }