diff --git a/.gitignore b/.gitignore
index 56dd8ca..03e6949 100644
--- a/.gitignore
+++ b/.gitignore
@@ -306,9 +306,7 @@ infra/docker/supabase/volumes/storage/
# OpenCode local config
# .opencode/ is now tracked - see .opencode/.gitignore for exclusions
-
-# Agents and skills
-.agents/
+midscene_run/
# Local git worktrees
.worktrees/
diff --git a/.opencode/commands/android-test.md b/.opencode/commands/android-test.md
new file mode 100644
index 0000000..5dad31f
--- /dev/null
+++ b/.opencode/commands/android-test.md
@@ -0,0 +1,24 @@
+---
+description: Run an Android automation test through Midscene Skills
+---
+
+You are running an Android mobile UI automation task for this project.
+
+Interpret the user arguments as the exact natural-language test goal:
+
+$ARGUMENTS
+
+Execution requirements:
+
+1. Verify that adb is available and that at least one Android device or emulator is connected.
+2. If no Android target is available, stop and report that the Android automation prerequisite is missing.
+3. Use the installed Midscene Android skill workflow to execute the requested UI actions on the Android emulator or device.
+4. Prefer acting on the current development build of the app when applicable.
+5. Capture visible evidence during the run when useful, especially the final screen state.
+6. At the end, report:
+ - whether the flow succeeded
+ - the exact failing step if any
+ - what was observed on screen
+ - what should be fixed next if this looked like a product bug
+
+Do not only describe a test plan. Actually perform the automation when prerequisites are available.
diff --git a/.opencode/commands/ios-test.md b/.opencode/commands/ios-test.md
new file mode 100644
index 0000000..8f4a255
--- /dev/null
+++ b/.opencode/commands/ios-test.md
@@ -0,0 +1,24 @@
+---
+description: Run an iOS automation test through Midscene Skills
+---
+
+You are running an iOS mobile UI automation task for this project.
+
+Interpret the user arguments as the exact natural-language test goal:
+
+$ARGUMENTS
+
+Execution requirements:
+
+1. Verify that WebDriverAgent is reachable at http://localhost:8100/status before doing any iOS action.
+2. If WebDriverAgent is not ready, stop and report that the iOS automation prerequisite is missing.
+3. Use the installed Midscene iOS skill workflow to execute the requested UI actions on the iOS simulator or device.
+4. Prefer acting on the current development build of the app when applicable.
+5. Capture visible evidence during the run when useful, especially the final screen state.
+6. At the end, report:
+ - whether the flow succeeded
+ - the exact failing step if any
+ - what was observed on screen
+ - what should be fixed next if this looked like a product bug
+
+Do not only describe a test plan. Actually perform the automation when prerequisites are available.
diff --git a/.opencode/opencode.json b/.opencode/opencode.json
new file mode 100644
index 0000000..12c787f
--- /dev/null
+++ b/.opencode/opencode.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "supabase": {
+ "type": "remote",
+ "enabled": true,
+ "url": "http://localhost:8001/mcp"
+ }
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
index 2b294e7..47ad053 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,3 +41,35 @@ Do not place backend/frontend implementation details here.
## Database Access
When viewing data in the database, use `supabase mcp` tools (`supabase_execute_sql`, `supabase_list_tables`, etc.) instead of direct queries or other methods.
+
+## Mobile Automation
+
+Use Midscene Skills for mobile UI automation.
+
+### When to trigger
+If the user asks to open app, navigate pages, tap, input text, scroll, verify UI, reproduce bug, or run mobile tests → treat as executable automation, not just explanation.
+
+### Platform
+- iOS → use Midscene iOS (requires WebDriverAgent at http://localhost:8100/status)
+- Android → use Midscene Android (requires `adb devices` available)
+
+If platform not specified:
+- Use current project platform if obvious
+- Otherwise ask
+
+### Preconditions
+- iOS: WDA must be ready
+- Android: device/emulator must be connected
+
+If not ready → stop and report missing requirement
+
+### Execution
+- Perform actual UI actions via Midscene Skills
+- Do not only describe test plan
+- Capture result (screen state / success / failure step)
+
+### Output
+Return:
+- success or failure
+- first failing step (if any)
+- key observation
diff --git a/QQ20260406-003407.png b/QQ20260406-003407.png
new file mode 100644
index 0000000..0fd1494
Binary files /dev/null and b/QQ20260406-003407.png differ
diff --git a/apps/.metadata b/apps/.metadata
index e8cf1e4..ad0e577 100644
--- a/apps/.metadata
+++ b/apps/.metadata
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
- revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6"
+ revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
channel: "stable"
project_type: app
@@ -13,14 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
- create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
- base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
- - platform: android
- create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
- base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: ios
- create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
- base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
# User provided section
diff --git a/apps/AGENTS.md b/apps/AGENTS.md
index 3efe608..fb1b795 100644
--- a/apps/AGENTS.md
+++ b/apps/AGENTS.md
@@ -48,7 +48,8 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
## Divination Terminology (Must)
- Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels.
-- Do not localize or translate canonical terms such as: 六爻、爻、动爻、静爻、六亲、六神、世爻、应爻、伏神、月建、日辰、月破、日冲、空亡、五行旺衰、上上签、中上签、中下签。
+- Do not localize or translate canonical terms such as: 六爻、爻、动爻、静爻、六亲、六神、世爻、应爻、伏神、月建、日辰、月破、日冲、空亡、五行旺衰。
+- Signature level labels (`上上签/中上签/中下签`) may be localized for UI display only, while protocol/storage values remain canonical Chinese.
- l10n can translate explanatory copy, but must not alter canonical divination terminology semantics.
## Reuse & Composition (Must)
diff --git a/apps/ios/Flutter/AppFrameworkInfo.plist b/apps/ios/Flutter/AppFrameworkInfo.plist
index 1dc6cf7..391a902 100644
--- a/apps/ios/Flutter/AppFrameworkInfo.plist
+++ b/apps/ios/Flutter/AppFrameworkInfo.plist
@@ -20,7 +20,5 @@
????
CFBundleVersion
1.0
- MinimumOSVersion
- 13.0
diff --git a/apps/ios/Flutter/Debug.xcconfig b/apps/ios/Flutter/Debug.xcconfig
index 592ceee..ec97fc6 100644
--- a/apps/ios/Flutter/Debug.xcconfig
+++ b/apps/ios/Flutter/Debug.xcconfig
@@ -1 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
diff --git a/apps/ios/Flutter/Release.xcconfig b/apps/ios/Flutter/Release.xcconfig
index 592ceee..c4855bf 100644
--- a/apps/ios/Flutter/Release.xcconfig
+++ b/apps/ios/Flutter/Release.xcconfig
@@ -1 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
diff --git a/apps/ios/Podfile b/apps/ios/Podfile
new file mode 100644
index 0000000..620e46e
--- /dev/null
+++ b/apps/ios/Podfile
@@ -0,0 +1,43 @@
+# Uncomment this line to define a global platform for your project
+# platform :ios, '13.0'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+ use_frameworks!
+
+ flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+ target 'RunnerTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_ios_build_settings(target)
+ end
+end
diff --git a/apps/ios/Runner/AppDelegate.swift b/apps/ios/Runner/AppDelegate.swift
index 6266644..c30b367 100644
--- a/apps/ios/Runner/AppDelegate.swift
+++ b/apps/ios/Runner/AppDelegate.swift
@@ -2,12 +2,15 @@ import Flutter
import UIKit
@main
-@objc class AppDelegate: FlutterAppDelegate {
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
- GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
}
diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist
index defc42d..fa67c11 100644
--- a/apps/ios/Runner/Info.plist
+++ b/apps/ios/Runner/Info.plist
@@ -2,6 +2,8 @@
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -24,6 +26,33 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
+ NSPhotoLibraryUsageDescription
+ 需要访问您的相册以选择并上传头像
+ NSPhotoLibraryAddUsageDescription
+ 需要将头像处理结果保存到您的相册
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ FlutterSceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
@@ -41,9 +70,5 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSupportsIndirectInputEvents
-
diff --git a/apps/ios/Runner/SceneDelegate.swift b/apps/ios/Runner/SceneDelegate.swift
new file mode 100644
index 0000000..b9ce8ea
--- /dev/null
+++ b/apps/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}
diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart
index 7cce766..b615d76 100644
--- a/apps/lib/app/app.dart
+++ b/apps/lib/app/app.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import '../core/auth/session_store.dart';
+import '../core/logging/logger.dart';
import '../data/network/api_client.dart';
import '../data/storage/local_kv_store.dart';
import '../features/auth/data/apis/auth_api.dart';
@@ -10,7 +11,9 @@ import '../features/auth/presentation/bloc/auth_bloc.dart';
import '../features/auth/presentation/bloc/auth_state.dart';
import '../features/auth/presentation/screens/login_screen.dart';
import '../features/divination/data/apis/divination_api.dart';
+import '../features/divination/data/models/divination_result.dart';
import '../features/home/presentation/screens/home_screen.dart';
+import '../features/settings/data/apis/profile_api.dart';
import '../features/settings/data/models/profile_settings.dart';
import '../l10n/app_localizations.dart';
import '../shared/widgets/app_loading_indicator.dart';
@@ -25,9 +28,11 @@ class EryaoApp extends StatefulWidget {
}
class _EryaoAppState extends State {
+ static final Logger _logger = getLogger('app.eryao_app');
final SessionStore _sessionStore = SessionStore(LocalKvStore());
late final AuthBloc _authBloc;
late final DivinationApi _divinationApi;
+ late final ProfileApi _profileApi;
Locale _locale = const Locale('zh');
ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale(
const Locale('zh'),
@@ -35,6 +40,11 @@ class _EryaoAppState extends State {
int _creditsBalance = 0;
bool _loadingCredits = false;
String? _loadedCreditsUserEmail;
+ bool _loadingHistory = false;
+ String? _loadedHistoryUserEmail;
+ List _historyRecords = const [];
+ bool _loadingProfile = false;
+ String? _loadedProfileUserEmail;
@override
void initState() {
@@ -48,6 +58,7 @@ class _EryaoAppState extends State {
);
final authApi = AuthApi(apiClient: apiClient);
_divinationApi = DivinationApi(apiClient: apiClient);
+ _profileApi = ProfileApi(apiClient: apiClient);
final authRepository = AuthRepositoryImpl(
authApi: authApi,
sessionStore: _sessionStore,
@@ -64,22 +75,192 @@ class _EryaoAppState extends State {
return;
}
_loadingCredits = true;
+ _refreshCredits(userEmail: userEmail).whenComplete(() {
+ _loadingCredits = false;
+ });
+ }
+
+ void _ensureHistoryLoaded(String userEmail) {
+ if (_loadingHistory) {
+ return;
+ }
+ if (_loadedHistoryUserEmail == userEmail) {
+ return;
+ }
+ _loadingHistory = true;
_divinationApi
- .getPointsBalance()
- .then((balance) {
+ .getHistoryRecords(userId: userEmail)
+ .then((records) {
if (!mounted) {
return;
}
setState(() {
- _creditsBalance = balance.availableBalance;
- _loadedCreditsUserEmail = userEmail;
+ _historyRecords = records;
+ _loadedHistoryUserEmail = userEmail;
});
})
+ .catchError((Object error, StackTrace stackTrace) {
+ _logger.warning(
+ message: 'Failed to load divination history',
+ extra: {
+ 'error': error.toString(),
+ 'stackTrace': stackTrace.toString(),
+ },
+ );
+ })
.whenComplete(() {
- _loadingCredits = false;
+ _loadingHistory = false;
});
}
+ Future _refreshCredits({required String userEmail}) async {
+ final balance = await _divinationApi.getPointsBalance();
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _creditsBalance = balance.availableBalance;
+ _loadedCreditsUserEmail = userEmail;
+ });
+ }
+
+ Future _handleDivinationCompleted(DivinationResultData result) async {
+ final user = _authBloc.state.user;
+ if (user == null) {
+ return;
+ }
+
+ final optimisticRecords = _mergeAndSortHistory([
+ result,
+ ..._historyRecords,
+ ]);
+
+ if (!mounted) {
+ return;
+ }
+
+ setState(() {
+ _historyRecords = optimisticRecords;
+ _loadedHistoryUserEmail = user.email;
+ });
+
+ try {
+ final records = await _divinationApi.getHistoryRecords(
+ userId: user.email,
+ );
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _historyRecords = _mergeAndSortHistory([
+ ...records,
+ ...optimisticRecords,
+ ]);
+ _loadedHistoryUserEmail = user.email;
+ });
+ } catch (error, stackTrace) {
+ _logger.warning(
+ message: 'Failed to refresh history after divination completion',
+ extra: {
+ 'error': error.toString(),
+ 'stackTrace': stackTrace.toString(),
+ },
+ );
+ }
+
+ try {
+ await _refreshCredits(userEmail: user.email);
+ } catch (error, stackTrace) {
+ _logger.warning(
+ message: 'Failed to refresh credits after divination completion',
+ extra: {
+ 'error': error.toString(),
+ 'stackTrace': stackTrace.toString(),
+ },
+ );
+ }
+ }
+
+ List _mergeAndSortHistory(
+ List input,
+ ) {
+ final seen = {};
+ final deduped = [];
+ for (final item in input) {
+ final key = _historyKey(item);
+ if (seen.add(key)) {
+ deduped.add(item);
+ }
+ }
+ deduped.sort(
+ (a, b) => b.params.divinationTime.compareTo(a.params.divinationTime),
+ );
+ return deduped;
+ }
+
+ String _historyKey(DivinationResultData item) {
+ return [
+ item.params.question,
+ item.binaryCode,
+ item.changedBinaryCode,
+ item.guaName,
+ item.targetGuaName,
+ item.signType,
+ ].join('|');
+ }
+
+ Future _refreshProfile({required String userEmail}) async {
+ if (_loadingProfile) {
+ return;
+ }
+ if (_loadedProfileUserEmail == userEmail) {
+ return;
+ }
+ _loadingProfile = true;
+ try {
+ final profile = await _profileApi.getProfile();
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _profileSettings = profile;
+ _loadedProfileUserEmail = userEmail;
+ });
+ } finally {
+ _loadingProfile = false;
+ }
+ }
+
+ Future _uploadAvatar(String filePath) async {
+ final updated = await _profileApi.uploadAvatar(filePath);
+ if (!mounted) {
+ return updated;
+ }
+ setState(() {
+ _profileSettings = updated;
+ });
+ return updated;
+ }
+
+ Future _saveProfileSettings(ProfileSettingsV1 next) async {
+ try {
+ final saved = await _profileApi.updateProfile(next);
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _profileSettings = saved;
+ });
+ } catch (error, stackTrace) {
+ _logger.error(
+ message: 'Failed to save profile settings via API',
+ error: error,
+ stackTrace: stackTrace,
+ );
+ rethrow;
+ }
+ }
+
@override
void dispose() {
_authBloc.dispose();
@@ -149,13 +330,19 @@ class _EryaoAppState extends State {
if (state.status == AuthStatus.authenticated && state.user != null) {
_ensureCreditsLoaded(state.user!.email);
+ _ensureHistoryLoaded(state.user!.email);
+ _refreshProfile(userEmail: state.user!.email);
return HomeScreen(
account: state.user!.email,
sessionStore: _sessionStore,
currentLocale: _locale,
profileSettings: _profileSettings,
+ historyRecords: _historyRecords,
coinBalance: _creditsBalance,
onLocaleChanged: _handleInterfaceLanguageChanged,
+ onProfileSettingsChanged: _saveProfileSettings,
+ onUploadAvatar: _uploadAvatar,
+ onDivinationCompleted: _handleDivinationCompleted,
onLogout: _authBloc.logout,
);
}
diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart
index bd5ba34..0440077 100644
--- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart
+++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart
@@ -56,13 +56,9 @@ class AuthBloc extends ChangeNotifier {
}
Future logout() async {
- Object? caughtError;
- StackTrace? caughtStackTrace;
try {
await _repository.logout();
} catch (error, stackTrace) {
- caughtError = error;
- caughtStackTrace = stackTrace;
_logger.error(
message: 'User logout failed: ${error.runtimeType}',
error: error.runtimeType.toString(),
@@ -72,9 +68,6 @@ class AuthBloc extends ChangeNotifier {
_logger.info(message: 'User logged out');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
- if (caughtError != null) {
- Error.throwWithStackTrace(caughtError, caughtStackTrace!);
- }
}
Future handleUnauthorized401() async {
diff --git a/apps/lib/features/auth/presentation/screens/login_screen.dart b/apps/lib/features/auth/presentation/screens/login_screen.dart
index 1b62b08..b7789d5 100644
--- a/apps/lib/features/auth/presentation/screens/login_screen.dart
+++ b/apps/lib/features/auth/presentation/screens/login_screen.dart
@@ -11,6 +11,7 @@ import '../../../settings/presentation/screens/legal_document_screen.dart';
import '../../../settings/presentation/utils/legal_document_assets.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
+import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
@@ -156,17 +157,48 @@ class _LoginScreenState extends State {
return l10n.errorRequestGeneric;
}
+ InputDecoration _inputDecoration({
+ required String hintText,
+ required IconData icon,
+ }) {
+ final colors = Theme.of(context).colorScheme;
+ return InputDecoration(
+ hintText: hintText,
+ filled: true,
+ fillColor: colors.surface.withValues(alpha: 0.92),
+ prefixIcon: Icon(icon, color: colors.primary),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ borderSide: BorderSide(color: colors.outlineVariant),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ borderSide: BorderSide(color: colors.outlineVariant),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ borderSide: BorderSide(color: colors.primary, width: 1.6),
+ ),
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: AppSpacing.lg,
+ vertical: AppSpacing.lg,
+ ),
+ );
+ }
+
void _showPolicyDialog(String title, String content) {
showDialog(
context: context,
- builder: (context) {
- return AlertDialog(
- title: Text(title),
- content: Text(content),
+ builder: (dialogContext) {
+ return AppModalDialog(
+ title: title,
+ message: content,
+ icon: Icons.description_outlined,
actions: [
- TextButton(
- onPressed: () => Navigator.of(context).pop(),
- child: Text(AppLocalizations.of(context)!.dialogConfirm),
+ AppModalDialogAction(
+ label: AppLocalizations.of(dialogContext)!.dialogConfirm,
+ primary: true,
+ onPressed: () => Navigator.of(dialogContext).pop(),
),
],
);
@@ -197,214 +229,271 @@ class _LoginScreenState extends State {
_isValidEmail && _codeController.text.length == 6 && _agreementChecked;
return Scaffold(
- body: GestureDetector(
- onTap: () => FocusScope.of(context).unfocus(),
- child: SafeArea(
- child: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: AppSpacing.xl,
- vertical: AppSpacing.lg,
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const SizedBox(height: AppSpacing.xxl),
- Container(
- width: double.infinity,
- padding: const EdgeInsets.all(AppSpacing.xl),
- decoration: BoxDecoration(
- color: colors.surface,
- borderRadius: BorderRadius.circular(AppRadius.lg),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- l10n.welcomeLogin,
- style: Theme.of(context).textTheme.headlineMedium,
- ),
- const SizedBox(height: AppSpacing.sm),
- Text(
- l10n.loginSubtitleEmail,
- style: Theme.of(context).textTheme.bodyLarge,
- ),
- ],
- ),
- ),
- const SizedBox(height: AppSpacing.xxl),
- Container(
- decoration: BoxDecoration(
- color: colors.surface,
- borderRadius: BorderRadius.circular(AppRadius.lg),
- ),
- child: TextField(
- controller: _emailController,
- keyboardType: TextInputType.emailAddress,
- onChanged: (_) => setState(() {}),
- decoration: InputDecoration(
- hintText: l10n.emailHint,
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(AppRadius.lg),
- borderSide: BorderSide.none,
- ),
- contentPadding: const EdgeInsets.symmetric(
- horizontal: AppSpacing.lg,
- vertical: AppSpacing.lg,
- ),
- ),
- ),
- ),
- const SizedBox(height: AppSpacing.lg),
- Row(
- children: [
- Expanded(
- child: Container(
- decoration: BoxDecoration(
- color: colors.surface,
- borderRadius: BorderRadius.circular(AppRadius.lg),
- ),
- child: TextField(
- controller: _codeController,
- keyboardType: TextInputType.number,
- maxLength: 6,
- onChanged: (_) => setState(() {}),
- decoration: InputDecoration(
- counterText: '',
- hintText: l10n.codeHint,
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(AppRadius.lg),
- borderSide: BorderSide.none,
- ),
- contentPadding: const EdgeInsets.symmetric(
- horizontal: AppSpacing.lg,
- vertical: AppSpacing.lg,
- ),
- ),
- ),
- ),
- ),
- const SizedBox(width: AppSpacing.sm),
- SizedBox(
- width: 130,
- height: 48,
- child: FilledButton(
- style: FilledButton.styleFrom(
- backgroundColor: colors.surfaceContainerHighest,
- foregroundColor: colors.primary,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(AppRadius.full),
- ),
- ),
- onPressed: _sendCode,
- child: Text(
- _isSending
- ? l10n.sending
- : _countdown > 0
- ? l10n.retryAfter(_countdown)
- : l10n.sendCode,
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: AppSpacing.xl),
- SizedBox(
- width: double.infinity,
- child: FilledButton(
- style: FilledButton.styleFrom(
- backgroundColor: colors.primary,
- foregroundColor: colors.onPrimary,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(AppRadius.full),
- ),
- padding: const EdgeInsets.symmetric(
- vertical: AppSpacing.md,
- ),
- ),
- onPressed: canLogin ? _login : null,
- child: Text(
- l10n.login,
- style: const TextStyle(fontSize: 16),
- ),
- ),
- ),
- const SizedBox(height: AppSpacing.md),
- Center(
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Checkbox(
- value: _agreementChecked,
- onChanged: (value) {
- setState(() {
- _agreementChecked = value ?? false;
- });
- },
- ),
- Flexible(
- child: RichText(
- text: TextSpan(
- style: Theme.of(context).textTheme.bodySmall,
- children: [
- TextSpan(text: l10n.agreementPrefix),
- TextSpan(
- text: l10n.privacyPolicy,
- style: TextStyle(
- color: colors.primary,
- decoration: TextDecoration.underline,
- ),
- recognizer: TapGestureRecognizer()
- ..onTap = () => _openLegalDocument(
- LegalDocumentType.privacyPolicy,
- ),
- ),
- TextSpan(text: l10n.agreementSeparator),
- TextSpan(
- text: l10n.termsOfService,
- style: TextStyle(
- color: colors.primary,
- decoration: TextDecoration.underline,
- ),
- recognizer: TapGestureRecognizer()
- ..onTap = () => _openLegalDocument(
- LegalDocumentType.termsOfService,
- ),
- ),
- TextSpan(text: l10n.agreementAnd),
- TextSpan(
- text: l10n.disclaimer,
- style: TextStyle(
- color: colors.primary,
- decoration: TextDecoration.underline,
- ),
- recognizer: TapGestureRecognizer()
- ..onTap = () => _showPolicyDialog(
- l10n.disclaimer,
- l10n.disclaimerContent,
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
- ),
- const Spacer(),
- Center(
- child: Text(
- l10n.icp,
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: colors.primary,
- fontWeight: FontWeight.w700,
- ),
- ),
- ),
- const SizedBox(height: AppSpacing.sm),
- ],
- ),
+ resizeToAvoidBottomInset: true,
+ body: Container(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ colors.secondaryContainer.withValues(alpha: 0.55),
+ colors.primaryContainer.withValues(alpha: 0.42),
+ colors.surfaceContainerLow,
+ ],
),
),
+ child: Stack(
+ children: [
+ Positioned(
+ top: -86,
+ right: -42,
+ child: Container(
+ width: 180,
+ height: 180,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: colors.primary.withValues(alpha: 0.1),
+ ),
+ ),
+ ),
+ Positioned(
+ bottom: -110,
+ left: -34,
+ child: Container(
+ width: 210,
+ height: 210,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: colors.secondary.withValues(alpha: 0.08),
+ ),
+ ),
+ ),
+ GestureDetector(
+ onTap: () => FocusScope.of(context).unfocus(),
+ child: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final bottomInset = MediaQuery.of(
+ context,
+ ).viewInsets.bottom;
+ return SingleChildScrollView(
+ keyboardDismissBehavior:
+ ScrollViewKeyboardDismissBehavior.onDrag,
+ padding: EdgeInsets.fromLTRB(
+ AppSpacing.xl,
+ AppSpacing.lg,
+ AppSpacing.xl,
+ AppSpacing.lg + bottomInset,
+ ),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SizedBox(height: AppSpacing.xxxl),
+ Center(
+ child: Column(
+ children: [
+ Container(
+ width: 88,
+ height: 88,
+ decoration: BoxDecoration(
+ color: colors.surface.withValues(
+ alpha: 0.9,
+ ),
+ borderRadius: BorderRadius.circular(
+ AppRadius.full,
+ ),
+ border: Border.all(
+ color: colors.primary.withValues(
+ alpha: 0.2,
+ ),
+ ),
+ ),
+ padding: const EdgeInsets.all(
+ AppSpacing.md,
+ ),
+ child: Image.asset(
+ 'assets/images/logo.png',
+ ),
+ ),
+ const SizedBox(height: AppSpacing.md),
+ Text(
+ l10n.appTitle,
+ style: Theme.of(context)
+ .textTheme
+ .titleLarge
+ ?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: AppSpacing.xxxl),
+ TextField(
+ controller: _emailController,
+ keyboardType: TextInputType.emailAddress,
+ textInputAction: TextInputAction.next,
+ onChanged: (_) => setState(() {}),
+ decoration: _inputDecoration(
+ hintText: l10n.emailHint,
+ icon: Icons.alternate_email,
+ ),
+ ),
+ const SizedBox(height: AppSpacing.lg),
+ Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _codeController,
+ keyboardType: TextInputType.number,
+ textInputAction: TextInputAction.done,
+ maxLength: 6,
+ onChanged: (_) => setState(() {}),
+ decoration: _inputDecoration(
+ hintText: l10n.codeHint,
+ icon: Icons.lock_outline,
+ ).copyWith(counterText: ''),
+ ),
+ ),
+ const SizedBox(width: AppSpacing.sm),
+ SizedBox(
+ width: 128,
+ height: 52,
+ child: FilledButton(
+ style: FilledButton.styleFrom(
+ backgroundColor: colors.primary,
+ foregroundColor: colors.onPrimary,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(
+ AppRadius.full,
+ ),
+ ),
+ ),
+ onPressed: _sendCode,
+ child: Text(
+ _isSending
+ ? l10n.sending
+ : _countdown > 0
+ ? l10n.retryAfter(_countdown)
+ : l10n.sendCode,
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: AppSpacing.xl),
+ SizedBox(
+ width: double.infinity,
+ child: FilledButton(
+ style: FilledButton.styleFrom(
+ backgroundColor: colors.primary,
+ foregroundColor: colors.onPrimary,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(
+ AppRadius.full,
+ ),
+ ),
+ padding: const EdgeInsets.symmetric(
+ vertical: AppSpacing.md,
+ ),
+ ),
+ onPressed: canLogin ? _login : null,
+ child: Text(
+ l10n.login,
+ style: const TextStyle(fontSize: 16),
+ ),
+ ),
+ ),
+ const SizedBox(height: AppSpacing.md),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Checkbox(
+ value: _agreementChecked,
+ onChanged: (value) {
+ setState(() {
+ _agreementChecked = value ?? false;
+ });
+ },
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.only(
+ top: AppSpacing.sm,
+ ),
+ child: RichText(
+ text: TextSpan(
+ style: Theme.of(context)
+ .textTheme
+ .bodySmall
+ ?.copyWith(color: colors.onSurface),
+ children: [
+ TextSpan(text: l10n.agreementPrefix),
+ TextSpan(
+ text: l10n.privacyPolicy,
+ style: TextStyle(
+ color: colors.primary,
+ decoration:
+ TextDecoration.underline,
+ ),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () =>
+ _openLegalDocument(
+ LegalDocumentType
+ .privacyPolicy,
+ ),
+ ),
+ TextSpan(
+ text: l10n.agreementSeparator,
+ ),
+ TextSpan(
+ text: l10n.termsOfService,
+ style: TextStyle(
+ color: colors.primary,
+ decoration:
+ TextDecoration.underline,
+ ),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () =>
+ _openLegalDocument(
+ LegalDocumentType
+ .termsOfService,
+ ),
+ ),
+ TextSpan(text: l10n.agreementAnd),
+ TextSpan(
+ text: l10n.disclaimer,
+ style: TextStyle(
+ color: colors.primary,
+ decoration:
+ TextDecoration.underline,
+ ),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () => _showPolicyDialog(
+ l10n.disclaimer,
+ l10n.disclaimerContent,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ ],
+ ),
),
);
}
diff --git a/apps/lib/features/divination/data/apis/divination_api.dart b/apps/lib/features/divination/data/apis/divination_api.dart
index 7c24d52..4fdc47e 100644
--- a/apps/lib/features/divination/data/apis/divination_api.dart
+++ b/apps/lib/features/divination/data/apis/divination_api.dart
@@ -8,6 +8,7 @@ import '../../../../core/network/api_problem.dart';
import '../../../../data/network/api_client.dart';
import '../models/divination_backend_models.dart';
import '../models/divination_params.dart';
+import '../models/divination_result.dart';
class DivinationApi {
const DivinationApi({required ApiClient apiClient}) : _apiClient = apiClient;
@@ -37,6 +38,67 @@ class DivinationApi {
return RunAcceptedData.fromJson(json);
}
+ Future> getHistoryRecords({
+ required String userId,
+ }) async {
+ final json = await _apiClient.getJson('/api/v1/agent/history');
+ final messagesRaw = json['messages'];
+ if (messagesRaw is! List) {
+ return const [];
+ }
+
+ final records = [];
+ for (final raw in messagesRaw) {
+ if (raw is! Map) {
+ continue;
+ }
+ if (raw['role'] != 'assistant') {
+ continue;
+ }
+ final agentOutputRaw = raw['agent_output'];
+ if (agentOutputRaw is! Map) {
+ continue;
+ }
+ final derivedRaw = agentOutputRaw['divination_derived'];
+ if (derivedRaw is! Map) {
+ continue;
+ }
+ try {
+ final derived = DerivedDivinationData.fromJson(derivedRaw);
+ final divinationTime = _resolveHistoryTime(raw, derived);
+ final params = DivinationParams(
+ method: _methodFromText(derived.divinationMethod),
+ questionType: _questionTypeFromText(derived.questionType),
+ question: derived.question,
+ divinationTime: divinationTime,
+ coinBalance: 0,
+ userId: userId,
+ );
+ final aggregate = DivinationRunAggregate(
+ derived: derived,
+ signLevel: _asString(agentOutputRaw['sign_level']),
+ summary: _asString(agentOutputRaw['summary']),
+ conclusion: _asStringList(agentOutputRaw['conclusion']),
+ focusPoints: _asStringList(agentOutputRaw['focus_points']),
+ advice: _asStringList(agentOutputRaw['advice']),
+ keywords: _asStringList(agentOutputRaw['keywords']),
+ answer: _asString(agentOutputRaw['answer']),
+ );
+ records.add(aggregate.toViewData(params));
+ } catch (error, stackTrace) {
+ _logger.warning(
+ message: 'Skip malformed history assistant message',
+ extra: {
+ 'error': error.toString(),
+ 'stackTrace': stackTrace.toString(),
+ },
+ );
+ continue;
+ }
+ }
+ return records;
+ }
+
Stream