Compare commits

...

10 Commits

307 changed files with 32575 additions and 1188 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ ERYAO_RUNTIME__TRUSTED_PROXY_IPS=[]
# Web 服务器配置(Uvicorn # Web 服务器配置(Uvicorn
############ ############
ERYAO_WEB__HOST=0.0.0.0 ERYAO_WEB__HOST=0.0.0.0
ERYAO_WEB__PORT=8000 ERYAO_WEB__PORT=5775
ERYAO_WEB__WORKERS=2 ERYAO_WEB__WORKERS=2
############ ############
@@ -79,5 +79,5 @@ ERYAO_CORS__ALLOW_ORIGINS=["http://localhost", "http://localhost:3000"]
############ ############
# Test相关 # Test相关
############ ############
ERYAO_TEST__PHONE=8613812345678 ERYAO_TEST__EMAIL=8613812345678
ERYAO_TEST__PASSWORD=Test@123456 ERYAO_TEST__PASSWORD=Test@123456
+1 -3
View File
@@ -306,9 +306,7 @@ infra/docker/supabase/volumes/storage/
# OpenCode local config # OpenCode local config
# .opencode/ is now tracked - see .opencode/.gitignore for exclusions # .opencode/ is now tracked - see .opencode/.gitignore for exclusions
midscene_run/
# Agents and skills
.agents/
# Local git worktrees # Local git worktrees
.worktrees/ .worktrees/
+24
View File
@@ -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.
+24
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"supabase": {
"type": "remote",
"enabled": true,
"url": "http://localhost:8001/mcp"
}
}
}
+37
View File
@@ -0,0 +1,37 @@
repos:
- repo: local
hooks:
- id: backend-ruff
name: backend ruff check
entry: uv run ruff check backend/src backend/tests
language: system
pass_filenames: false
files: ^(backend/|pyproject\.toml|uv\.lock)
- id: backend-python-syntax
name: backend python syntax check
entry: uv run python -m py_compile
language: system
files: ^backend/.*\.py$
- id: backend-basedpyright
name: backend basedpyright check
entry: uv run basedpyright backend/src
language: system
pass_filenames: false
files: ^backend/
- id: backend-pytest
name: backend pytest
entry: uv run pytest backend/tests
language: system
pass_filenames: false
files: ^(backend/|pyproject\.toml|uv\.lock)
- id: apps-flutter-analyze
name: apps flutter analyze
entry: bash -lc 'cd apps && flutter analyze'
language: system
pass_filenames: false
files: ^apps/
+32
View File
@@ -41,3 +41,35 @@ Do not place backend/frontend implementation details here.
## Database Access ## 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. 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

+5 -8
View File
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited. # This file should be version controlled and should not be manually edited.
version: version:
revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
channel: "stable" channel: "stable"
project_type: app project_type: app
@@ -13,14 +13,11 @@ project_type: app
migration: migration:
platforms: platforms:
- platform: root - platform: root
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: android
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
- platform: ios - platform: ios
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
# User provided section # User provided section
+8
View File
@@ -45,6 +45,13 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
- `AppTheme.light` / `AppTheme.dark` provide complete `ColorScheme` (light + dark). `MaterialApp` wires them via `theme:` / `darkTheme:`. - `AppTheme.light` / `AppTheme.dark` provide complete `ColorScheme` (light + dark). `MaterialApp` wires them via `theme:` / `darkTheme:`.
- If a semantic slot is missing from `ColorScheme`, add it to `AppTheme` — do not bypass `colorScheme` with hardcoded values. - If a semantic slot is missing from `ColorScheme`, add it to `AppTheme` — do not bypass `colorScheme` with hardcoded values.
## 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: 六爻、爻、动爻、静爻、六亲、六神、世爻、应爻、伏神、月建、日辰、月破、日冲、空亡、五行旺衰。
- 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) ## Reuse & Composition (Must)
- Prefer `apps/lib/shared/widgets/` before adding new components. - Prefer `apps/lib/shared/widgets/` before adding new components.
@@ -57,6 +64,7 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
- User feedback: `Toast` / `AppBanner` only. - User feedback: `Toast` / `AppBanner` only.
- Loading indicators: `AppLoadingIndicator` only. - Loading indicators: `AppLoadingIndicator` only.
- Form pages should default to keyboard-overlay behavior to avoid full-page layout jumps. - Form pages should default to keyboard-overlay behavior to avoid full-page layout jumps.
- `ToastType.info` should be minimized: do not show informational toast for normal success paths (e.g., login success). Prefer silent success unless user must take action.
## Interaction & Feedback (Must) ## Interaction & Feedback (Must)
+21 -10
View File
@@ -1,16 +1,27 @@
# meeyao_qianwen # eryao apps
A new Flutter project. Flutter client for `觅爻签问`.
## Getting Started ## Debug startup with backend injection
This project is a starting point for a Flutter application. This app supports injecting backend URL at startup (same pattern as social-app):
A few resources to get you started if this is your first Flutter project: - Dart read path: `lib/core/config/env.dart`
- Injection key: `BACKEND_URL`
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) ### Direct command
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the ```bash
[online documentation](https://docs.flutter.dev/), which offers tutorials, flutter run --dart-define=BACKEND_URL=http://192.168.1.100:5775
samples, guidance on mobile development, and a full API reference. ```
### Script command
```bash
./tool/run-dev.sh --backend-url http://192.168.1.100:5775
```
If `BACKEND_URL` is not provided, fallback is:
- Android emulator: `http://10.0.2.2:5775`
- Others: `http://localhost:5775`
+26 -6
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@@ -6,7 +8,7 @@ plugins {
} }
android { android {
namespace = "com.meeyao.meeyao_qianwen" namespace = "com.meeyao.qianwen"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@@ -20,8 +22,7 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.meeyao.qianwen"
applicationId = "com.meeyao.meeyao_qianwen"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
@@ -30,11 +31,30 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. signingConfig = if (keystorePropertiesFile.exists()) {
// Signing with the debug keys for now, so `flutter run --release` works. signingConfigs.getByName("release")
signingConfig = signingConfigs.getByName("debug") } else {
signingConfigs.getByName("debug")
}
} }
} }
} }
@@ -1,8 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application <application
android:label="meeyao_qianwen" android:label="@string/app_name"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -1,4 +1,4 @@
package com.meeyao.meeyao_qianwen package com.meeyao.qianwen
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">MeiYao Divination</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">觅爻签问</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="." />
</full-backup-content>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
+4
View File
@@ -0,0 +1,4 @@
storePassword=your_store_password
keyPassword=your_key_password
keyAlias=upload
storeFile=release.jks
Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+13
View File
@@ -0,0 +1,13 @@
# About Us
Welcome to MeiYao Divination, an AI-assisted platform for interpreting traditional Six-Line divination and opening a window into classical Chinese wisdom.
Six-Line divination comes from the deep philosophical system of the *I Ching*. It reflects the ancient view that intention, time, and the changing world are connected. Once a hexagram is formed, it can be interpreted together with line texts and rules such as the Five Elements and GanZhi interactions to understand possible trends and outcomes.
MeiYao Divination is built on this idea. Its core value is to help users step outside narrow thinking, understand contradictions, opportunities, and risks from a broader trend perspective, and make calmer, more thoughtful decisions. We hope AI can become a modern bridge to this traditional wisdom.
## Important Notice
All divination interpretations are generated by AI and are for entertainment and reference only. They must not be used as the sole basis for business, medical, or other professional decisions.
Yue ICP 2025428416-1A
+63
View File
@@ -0,0 +1,63 @@
# Privacy Policy
Dear user,
Welcome to MeiYao Divination. We understand that your privacy is important, and we take the protection of personal information seriously. This policy explains how we collect, use, store, and share your personal information, and how you can access and manage it.
## 1. Information We Collect
### Information you provide directly
- Account registration information, such as your phone number or verification code
- Profile information you choose to fill in
- Divination-related inputs and results you create in the app
### Information collected automatically
- Device information, such as model, system version, and device settings
- Log information, such as IP address, access time, page visits, and operation records
## 2. How We Use Information
- To provide divination services and improve AI interpretation quality
- To manage accounts and protect account security
- To improve product quality through usage analysis
- To contact you with service notifications, support, or feedback follow-up
## 3. Storage of Information
Personal information collected in China is generally stored on servers located within China. We only keep personal information for as long as necessary to meet legal obligations and service purposes, after which it will be deleted or anonymized.
## 4. Sharing of Information
We do not share personal information with third parties except in limited circumstances, such as:
- when you give clear consent,
- when we work with service providers under proper safeguards,
- when disclosure is required by law,
- or when business restructuring, merger, or acquisition requires lawful transfer.
## 5. Your Rights
You may request access to, correction of, or deletion of your personal information, and you may also request account cancellation. Please note that account cancellation may make related data unrecoverable.
## 6. Protection of Minors
If you are under 14 years old, please use the service under the guidance of a parent or legal guardian and obtain their prior consent.
## 7. Security of Personal Information
We use reasonable organizational and technical measures, including encryption, access control, auditing, and monitoring, to protect personal information against unauthorized access, disclosure, use, modification, damage, or loss.
## 8. Policy Updates
We may update this policy from time to time due to legal, business, or service changes. Material changes will be communicated in a prominent way.
## 9. Contact Us
If you have any questions or suggestions regarding this privacy policy, please contact us at:
- xuyunlong@xunmee.com
Xunmee Technology (Shenzhen) Co., Ltd.
June 1, 2025
+36
View File
@@ -0,0 +1,36 @@
# Terms of Service
## Chapter 1 General
MeiYao Divination is developed, operated, and maintained by Xunmee Technology (Shenzhen) Co., Ltd. By downloading, installing, registering, signing in, or otherwise using the app, you confirm that you have read, understood, and accepted these terms.
## Chapter 2 Service Description
MeiYao Divination provides AI-based divination interpretation services, including manual and automatic casting flows. Service interruption caused by maintenance, failure, force majeure, or other reasonable causes does not constitute a breach.
## Chapter 3 User Accounts and Information Security
Users must have proper legal capacity, provide true and valid registration information, and keep account credentials secure. Necessary personal information may be collected and processed according to the privacy policy.
## Chapter 4 Intellectual Property
All content of MeiYao Divination, including software, text, images, audio, video, charts, trademarks, and domains, is protected by law. Reverse engineering, decompilation, disassembly, or any attempt to obtain source code without written permission is strictly prohibited.
## Chapter 5 User Conduct
Users may not publish unlawful content, infringe on the rights of others, disrupt normal service operation, exploit vulnerabilities, or conduct unauthorized commercial activity. The app may warn, restrict, suspend, or ban accounts that violate these rules and may pursue legal liability.
## Chapter 6 Liability and Disclaimer
Users are responsible for losses caused by their own violations of these terms. AI-generated divination results are for reference only and must not be treated as the sole basis for real-world decisions. Users assume the related risks.
## Chapter 7 Dispute Resolution
These terms are governed by the laws of the People's Republic of China. Disputes should first be resolved through friendly consultation. If consultation fails, either party may bring the dispute to the competent court where Xunmee Technology is registered.
## Chapter 8 Miscellaneous
Notices may be delivered through contact information, system messages, internal messages, or announcements. If you need to contact Xunmee Technology, please email xuyunlong@xunmee.com.
Xunmee Technology (Shenzhen) Co., Ltd.
June 1, 2025
+13
View File
@@ -0,0 +1,13 @@
# 关于我们
你好,欢迎来到觅爻签问,这是一个借助于 AI 解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。
六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。得到卦象后,再结合《易经》中的爻辞和某些特定规律,如五行生克、干支冲合等,分析各要素间的发展趋势,最终推断出事物可能的走向。
觅爻签问就是基于这样的思路而开发出来的平台,它的核心价值在于帮助你跳出局限思维,从事物全局和演变趋势的角度看清现状的矛盾、潜在机会和风险点,为你的判断和行动提供多一个维度的参考信息,让你能更理性、更周全地做决定。用 AI 解锁古老智慧,让觅爻签问成为你探索趋势、明晰方向的现代助手吧!
## 特别提醒
卦象解读结果均由 AI 生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。
粤ICP备2025428416号-1A
+84
View File
@@ -0,0 +1,84 @@
# 隐私政策
尊敬的用户:
欢迎使用觅爻签问 APP(以下简称“觅爻”)!我们深知您的隐私对于您至关重要,因此我们非常重视保护您的个人信息。本隐私政策将向您详细说明我们在您使用服务时如何收集、使用、存储和共享您的个人信息,以及您如何访问和管理这些信息。请在使用我们的 APP 之前,仔细阅读并充分理解本隐私政策的全部内容。
## 一、我们收集哪些您的个人信息
### 1. 您主动提供的信息
- 账号注册信息:当您注册觅爻账号时,我们会收集您的手机号码、验证码等信息,以便为您创建账号并为您提供服务。
- 个人资料信息:您可以在个人资料页面选择填写姓名、性别、出生日期、个人签名等信息。这些信息有助于我们提供更个性化的解卦服务,但您不提供这些信息不会影响 APP 的基础功能使用。
- 解卦相关信息:在您使用解卦功能时,我们会收集您输入的问题、选择的解卦方式以及对应的解卦结果,用于提供服务和优化算法。
### 2. 我们自动收集的信息
- 设备信息:设备型号、操作系统版本、设备设置、唯一设备标识符等,用于性能优化、兼容性处理以及安全管理。
- 日志信息:包括 IP 地址、访问时间、访问页面、操作行为、网络请求信息等,用于分析用户行为和改进服务。
## 二、我们如何使用您的个人信息
### 1. 提供和优化我们的服务
- 利用您提供的卦象问题、解卦方式等信息,结合 AI 解卦算法为您生成解读结果,并不断优化解卦算法,提高结果的准确性和可靠性。
### 2. 账号管理和服务运营
- 账号安全:使用注册信息进行登录验证,并结合设备信息和日志信息进行安全监测,防范账号被盗用和异常登录等风险。
- 服务改进:分析使用行为数据,了解用户需求、发现功能缺陷和性能瓶颈,以便持续优化产品体验。
### 3. 与您沟通和联系
- 服务通知:通过消息通知等方式发送服务更新、活动公告和版本变更信息。
- 用户反馈与客服支持:当您提出咨询、反馈或投诉时,我们可能使用您的联系方式与您沟通,并记录沟通内容用于服务改进。
## 三、我们如何存储您的个人信息
### 1. 存储地点
我们在中华人民共和国境内收集和产生的个人信息将原则上存储于中华人民共和国境内的服务器上。如因业务需要向境外实体提供您的个人信息,我们将依法履行相应程序并确保信息得到充分保护。
### 2. 存储期限
我们只会在符合法律要求及实现服务目的所必需的最短时间范围内存储您的个人信息。对于您主动提供且明确同意长期存储的信息(如解卦历史记录),我们会持续保存,除非您主动要求删除或法律法规另有规定。在超出存储期限后,我们会对个人信息进行删除或匿名化处理。
## 四、我们如何共享您的个人信息
我们不会与第三方共享您的个人信息,除非存在以下情况:
1. 获得您的明确同意;
2. 与服务提供商合作,且对方仅能在约定范围内处理必要信息,并承担保密与安全义务;
3. 法律法规要求,或为保护我们、用户或公众的合法权益所必需;
4. 涉及企业收购、合并、重组或破产等情形,在此情况下我们会要求新的持有人继续受本隐私政策约束。
## 五、您的权利
1. 访问和更正个人信息:您有权访问并更正您的个人信息。
2. 删除个人信息:在法律规定或服务终止等特定场景下,您可以申请删除个人信息。
3. 注销账号:您可以申请注销账号。注销是不可逆操作,相关数据和信息可能无法恢复。
## 六、未成年人保护
我们非常重视对未成年人个人信息的保护。如果您是未满 14 周岁的未成年人,请在父母或法定监护人的指导下使用服务,并确保事先获得其同意。
## 七、您的个人信息安全
我们采取合理的安全措施和技术手段,保护您的个人信息免遭未经授权的访问、公开披露、使用、修改、损坏或丢失,包括但不限于:
- 加密、匿名化处理等技术措施;
- 严格的访问控制和权限管理;
- 定期安全审计、监控和应急响应机制。
## 八、本隐私政策的更新
我们可能会根据业务发展、法律法规变化或服务调整适时更新本隐私政策。对于重大变更,我们会通过显著方式通知您。
## 九、如何联系我们
如果您对本隐私政策有任何疑问、意见或建议,或您在使用 APP 过程中遇到与个人信息相关的问题,您可以通过以下方式联系我们:
- 联系邮箱:xuyunlong@xunmee.com
洵觅科技(深圳)有限公司
2025 年 6 月 1 日
+80
View File
@@ -0,0 +1,80 @@
# 服务条款
## 第一章 总则
### 第一条 服务条款的接受
1. 欢迎使用觅爻签问 APP(以下简称“觅爻”)。觅爻由洵觅科技(深圳)有限公司开发、运营和维护,旨在为用户提供实际、有趣的解卦体验。
2. 用户在使用觅爻服务之前,请务必仔细阅读并充分理解本服务条款。一旦用户通过下载、安装、注册、登录、使用等任一方式开始使用觅爻及其相关服务,即表示用户已充分理解并完全接受本服务条款。
3. 如果用户不同意本服务条款的任何内容,或者无法准确理解条款中任何内容,请不要进行后续操作。
### 第二条 服务条款的变更和修改
1. 洵觅科技有权在必要时对本服务条款进行修改和更新,且无须另行单独通知用户。修改后的服务条款一旦在觅爻上公布,即代替原来的服务条款。
2. 如用户不同意修改内容,用户有权停止使用觅爻及相关服务;若继续使用,则视为已阅读、理解并接受修改后的条款。
## 第二章 服务说明
### 第一条 觅爻服务内容
觅爻提供基于人工智能技术的解卦服务,包括但不限于手动起卦、自动起卦等功能,内容涵盖卦象含义、运势分析与建议等易学解释。
### 第二条 服务的中断与终止
1. 因系统维护、检修、升级等造成的合理中断或暂停,不属于洵觅科技违约。
2. 若用户违反本服务条款,洵觅科技有权在不事先通知的情况下终止或中止向用户提供服务。
3. 如发生不可抗力或超出合理控制范围的事件,导致服务无法正常提供或终止,洵觅科技将在合理范围内尽力减少影响,但不承担间接损失责任。
## 第三章 用户账号与信息安全
### 第一条 账号注册与使用
1. 用户应确保自己具备相应民事行为能力,或已经取得法定代理人同意。
2. 用户在注册过程中应提供真实、准确、完整、有效的信息,并及时更新。
3. 用户应妥善保管账号及身份验证信息,不得转让、出租、出借或与他人共享账号。
### 第二条 信息安全与隐私保护
1. 洵觅科技尊重用户隐私,并按照隐私政策收集、使用和存储用户个人信息。
2. 洵觅科技采用合理的安全措施和技术手段保护用户信息安全,但任何网络服务都无法保证百分之百安全,用户应自行承担相应风险。
3. 未经用户明确同意或法律要求,洵觅科技不会向第三方共享或披露用户个人信息。
## 第四章 知识产权声明
1. 觅爻的整体内容,包括但不限于文字、软件、声音、图片、视频、图表,以及相关商标、标识、域名等,均受法律保护。未经书面同意,用户不得复制、使用、修改、出租、出借、出售或传播上述内容。
2. 严禁通过反向工程、反编译、反汇编、数据截获、调试等方式尝试获取觅爻源代码或底层技术逻辑架构。对于此类行为,洵觅科技将依法追究民事、行政及刑事责任。
## 第五章 用户行为规范
### 第一条 禁止的用户行为
用户不得:
- 发布或传播违反法律法规的内容;
- 侵犯他人知识产权、名誉权、肖像权、隐私权等合法权益;
- 干扰或破坏觅爻服务正常运行;
- 利用漏洞牟利、破坏或影响他人正常使用;
- 未经书面同意进行销售、广告、推广等商业活动。
### 第二条 合规使用要求
用户应遵守本服务条款以及觅爻内发布的其他规则和公告。对于任何违反行为,洵觅科技有权根据情节采取警告、限制功能、封禁账号等措施,并保留追究法律责任的权利。
## 第六章 法律责任与免责条款
1. 若用户违反本服务条款导致洵觅科技或其关联公司遭受损失,用户应承担赔偿责任。
2. 对于用户因依赖解卦结果所作出的任何决策及其后果,洵觅科技不承担责任。解卦结果仅供参考,不能作为实际决策的唯一依据。
## 第七章 争议解决
1. 本服务条款适用中华人民共和国法律。
2. 因本服务条款引起的争议,双方应先友好协商;协商不成的,任何一方均有权向洵觅科技公司注册地有管辖权的人民法院提起诉讼。
## 第八章 其他条款
1. 洵觅科技可通过联系方式、系统消息、站内信、公告等方式向用户发送通知,通知视为有效送达。
2. 若用户需要联系洵觅科技,可通过邮箱 xuyunlong@xunmee.com 提交请求或反馈。
3. 本服务条款中的任何条款因任何原因无效或不可执行,不影响其余条款的效力。
洵觅科技(深圳)有限公司
2025 年 6 月 1 日
-2
View File
@@ -20,7 +20,5 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict> </dict>
</plist> </plist>
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
+43
View File
@@ -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
+5 -2
View File
@@ -2,12 +2,15 @@ import Flutter
import UIKit import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
} }
@@ -1,122 +1 @@
{ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+29 -4
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -24,6 +26,33 @@
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问您的相册以选择并上传头像</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要将头像处理结果保存到您的相册</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -41,9 +70,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict> </dict>
</plist> </plist>
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+359
View File
@@ -0,0 +1,359 @@
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';
import '../features/auth/data/repositories/auth_repository.dart';
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';
import 'app_theme.dart';
import 'di/injection.dart';
class EryaoApp extends StatefulWidget {
const EryaoApp({super.key});
@override
State<EryaoApp> createState() => _EryaoAppState();
}
class _EryaoAppState extends State<EryaoApp> {
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'),
);
int _creditsBalance = 0;
bool _loadingCredits = false;
String? _loadedCreditsUserEmail;
bool _loadingHistory = false;
String? _loadedHistoryUserEmail;
List<DivinationResultData> _historyRecords = const <DivinationResultData>[];
bool _loadingProfile = false;
String? _loadedProfileUserEmail;
@override
void initState() {
super.initState();
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: _sessionStore.getToken,
onUnauthorized: () {
return _authBloc.handleUnauthorized401();
},
);
final authApi = AuthApi(apiClient: apiClient);
_divinationApi = DivinationApi(apiClient: apiClient);
_profileApi = ProfileApi(apiClient: apiClient);
final authRepository = AuthRepositoryImpl(
authApi: authApi,
sessionStore: _sessionStore,
);
_authBloc = AuthBloc(repository: authRepository);
_bootstrap();
}
void _ensureCreditsLoaded(String userEmail) {
if (_loadingCredits) {
return;
}
if (_loadedCreditsUserEmail == userEmail) {
return;
}
_loadingCredits = true;
_refreshCredits(userEmail: userEmail).whenComplete(() {
_loadingCredits = false;
});
}
void _ensureHistoryLoaded(String userEmail) {
if (_loadingHistory) {
return;
}
if (_loadedHistoryUserEmail == userEmail) {
return;
}
_loadingHistory = true;
_divinationApi
.getHistoryRecords(userId: userEmail)
.then((records) {
if (!mounted) {
return;
}
setState(() {
_historyRecords = records;
_loadedHistoryUserEmail = userEmail;
});
})
.catchError((Object error, StackTrace stackTrace) {
_logger.warning(
message: 'Failed to load divination history',
extra: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
})
.whenComplete(() {
_loadingHistory = false;
});
}
Future<void> _refreshCredits({required String userEmail}) async {
final balance = await _divinationApi.getPointsBalance();
if (!mounted) {
return;
}
setState(() {
_creditsBalance = balance.availableBalance;
_loadedCreditsUserEmail = userEmail;
});
}
Future<void> _handleDivinationCompleted(DivinationResultData result) async {
final user = _authBloc.state.user;
if (user == null) {
return;
}
final optimisticRecords = _mergeAndSortHistory(<DivinationResultData>[
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(<DivinationResultData>[
...records,
...optimisticRecords,
]);
_loadedHistoryUserEmail = user.email;
});
} catch (error, stackTrace) {
_logger.warning(
message: 'Failed to refresh history after divination completion',
extra: <String, dynamic>{
'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: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
}
}
List<DivinationResultData> _mergeAndSortHistory(
List<DivinationResultData> input,
) {
final seen = <String>{};
final deduped = <DivinationResultData>[];
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<void> _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<ProfileSettingsV1> _uploadAvatar(String filePath) async {
final updated = await _profileApi.uploadAvatar(filePath);
if (!mounted) {
return updated;
}
setState(() {
_profileSettings = updated;
});
return updated;
}
Future<void> _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();
super.dispose();
}
Future<void> _bootstrap() async {
final localeCode = await _sessionStore.getLocaleCode();
final locale = localeCode == 'en' ? const Locale('en') : const Locale('zh');
if (mounted) {
setState(() {
_locale = locale;
_profileSettings = ProfileSettingsV1.defaultsForLocale(locale);
});
}
await _authBloc.start();
}
Future<void> _handleInterfaceLanguageChanged(String languageTag) async {
final locale = localeFromLanguageTag(languageTag);
await _sessionStore.saveLocaleCode(locale.languageCode);
if (!mounted) {
return;
}
setState(() {
_locale = locale;
_profileSettings = _profileSettings.copyWith(
preferences: _profileSettings.preferences.copyWith(
interfaceLanguage: languageTag,
),
);
});
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _authBloc,
builder: (context, _) {
return MaterialApp(
debugShowCheckedModeBanner: false,
onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
locale: _locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
home: _buildHomeByAuthState(_authBloc.state),
);
},
);
}
Widget _buildHomeByAuthState(AuthState state) {
if (state.status == AuthStatus.initial ||
state.status == AuthStatus.loading) {
return const Scaffold(
body: Center(
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
),
);
}
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,
);
}
return LoginScreen(
currentLocale: _locale,
onLocaleChanged: (_) {},
onRequestOtp: _authBloc.sendOtp,
onLoginWithOtp: (email, otp) {
return _authBloc.loginWithOtp(email: email, otp: otp);
},
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import '../shared/theme/app_color_palette.dart';
class AppTheme {
static const Color _primary = Color(0xFF673AB7);
static const Color _accent = Color(0xFF9C27B0);
static const Color _scaffold = Color(0xFFF8F8F8);
static const Color _textHigh = Color(0xFF333333);
static const Color _textMid = Color(0xFF666666);
static const Color _textLow = Color(0xFF999999);
static ThemeData light() {
const colorScheme = ColorScheme.light(
primary: _primary,
onPrimary: Color(0xFFFFFFFF),
secondary: _accent,
onSecondary: Color(0xFFFFFFFF),
surface: Color(0xFFFFFFFF),
onSurface: _textHigh,
error: Color(0xFFB00020),
onError: Color(0xFFFFFFFF),
outline: Color(0xFFDDDDDD),
surfaceContainerHighest: Color(0xFFF0E6FF),
surfaceContainerHigh: Color(0xFFF4F5F7),
surfaceContainerLow: Color(0xFFFAFAFA),
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: _scaffold,
textTheme: const TextTheme(
headlineMedium: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: _textHigh,
),
titleLarge: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: _textHigh,
),
titleMedium: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _textHigh,
),
bodyLarge: TextStyle(fontSize: 16, color: _textMid),
bodyMedium: TextStyle(fontSize: 14, color: _textMid),
bodySmall: TextStyle(fontSize: 12, color: _textLow),
),
extensions: const <ThemeExtension<dynamic>>[
AppColorPalette(
accentPurple: _accent,
historyGoldBg: Color(0xFFFFF8E1),
historyGoldText: Color(0xFFFFB300),
historyBlueBg: Color(0xFFE6F7FF),
historyBlueText: Color(0xFF1890FF),
historyGrayBg: Color(0xFFF5F5F5),
historyGrayText: Color(0xFF9E9E9E),
categoryCareerBg: Color(0xFFF0E6FF),
categoryCareerText: Color(0xFF673AB7),
categoryLoveBg: Color(0xFFFFF3E0),
categoryLoveText: Color(0xFFFF9800),
categoryMoneyBg: Color(0xFFE8F5E9),
categoryMoneyText: Color(0xFF4CAF50),
notificationDot: Color(0xFFE53935),
warning: Color(0xFFF57C00),
warningContainer: Color(0xFFFFF3E0),
onWarningContainer: Color(0xFF8A4B00),
),
],
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import '../../core/config/env.dart';
class AppDependencies {
const AppDependencies({required this.backendUrl});
final String backendUrl;
}
AppDependencies? _appDependencies;
AppDependencies get appDependencies {
return _appDependencies ?? AppDependencies(backendUrl: Env.backendUrl);
}
Future<void> configureDependencies() async {
_appDependencies = AppDependencies(backendUrl: Env.backendUrl);
}
+69
View File
@@ -0,0 +1,69 @@
import '../../data/storage/local_kv_store.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SessionStore {
SessionStore(this._kvStore);
final LocalKvStore _kvStore;
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
static const String _tokenKey = 'auth_token';
static const String _refreshTokenKey = 'auth_refresh_token';
static const String _emailKey = 'saved_email';
static const String _welcomeReadKey = 'has_seen_welcome_dialog';
static const String _localeKey = 'selected_locale';
Future<void> saveToken(String token) async {
await _secureStorage.write(key: _tokenKey, value: token);
}
Future<String?> getToken() async {
return _secureStorage.read(key: _tokenKey);
}
Future<void> clearToken() async {
await _secureStorage.delete(key: _tokenKey);
}
Future<void> saveRefreshToken(String refreshToken) async {
await _secureStorage.write(key: _refreshTokenKey, value: refreshToken);
}
Future<String?> getRefreshToken() async {
return _secureStorage.read(key: _refreshTokenKey);
}
Future<void> clearRefreshToken() async {
await _secureStorage.delete(key: _refreshTokenKey);
}
Future<void> saveEmail(String email) async {
await _secureStorage.write(key: _emailKey, value: email);
}
Future<String?> getEmail() async {
return _secureStorage.read(key: _emailKey);
}
Future<void> clearEmail() async {
await _secureStorage.delete(key: _emailKey);
}
Future<void> setWelcomeRead(bool value) async {
await _kvStore.setBool(_welcomeReadKey, value);
}
Future<bool> hasReadWelcome() async {
return _kvStore.getBool(_welcomeReadKey);
}
Future<void> saveLocaleCode(String localeCode) async {
await _kvStore.setString(_localeKey, localeCode);
}
Future<String?> getLocaleCode() async {
return _kvStore.getString(_localeKey);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'dart:io';
class Env {
static String get backendUrl {
final injected = const String.fromEnvironment('BACKEND_URL');
if (injected.isNotEmpty && injected != 'false') {
return injected;
}
if (Platform.isAndroid) {
return 'http://10.0.2.2:5775';
}
return 'http://localhost:5775';
}
static Future<void> init() async {}
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
import 'logger.dart';
class AppErrorHandler {
final Logger _logger = getLogger('flutter.error');
void register() {
FlutterError.onError = (details) {
_logger.error(
message: 'FlutterError: ${details.exceptionAsString()}',
error: details.exceptionAsString(),
stackTrace: details.stack ?? StackTrace.current,
extra: {'context': 'FlutterError.onError'},
);
FlutterError.presentError(details);
};
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'log_entry.dart';
enum LogOutput { console, file }
class LogConfig {
final LogLevel minLevel;
final LogOutput output;
final String logFileName;
final String logDir;
const LogConfig({
this.minLevel = LogLevel.debug,
this.output = LogOutput.console,
this.logFileName = 'app.log',
this.logDir = 'logs',
});
static LogConfig forDebug() =>
const LogConfig(minLevel: LogLevel.debug, output: LogOutput.console);
static LogConfig forRelease() => const LogConfig(
minLevel: LogLevel.warning,
output: LogOutput.file,
logFileName: 'app.log',
logDir: 'logs',
);
}
+78
View File
@@ -0,0 +1,78 @@
enum LogLevel { debug, info, warning, error }
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String message;
final String module;
final String? funcName;
final int? lineNo;
final String? errorType;
final String? errorMessage;
final String? stackTrace;
final Map<String, dynamic>? extra;
LogEntry({
required this.timestamp,
required this.level,
required this.message,
required this.module,
this.funcName,
this.lineNo,
this.errorType,
this.errorMessage,
this.stackTrace,
this.extra,
});
Map<String, dynamic> toJson() => {
'timestamp': timestamp.toIso8601String(),
'level': level.name,
'message': message,
'module': module,
if (funcName != null) 'func_name': funcName,
if (lineNo != null) 'line_no': lineNo,
if (errorType != null) 'error_type': errorType,
if (errorMessage != null) 'error_message': errorMessage,
if (stackTrace != null) 'stack_trace': stackTrace,
if (extra != null && extra!.isNotEmpty) 'extra': extra,
};
String toConsoleString() {
final ts = timestamp.toIso8601String();
final location = [
if (funcName != null) funcName,
if (lineNo != null) '@$lineNo',
].join('');
final locationStr = location.isNotEmpty ? ' [$location]' : '';
final errorStr = errorType != null ? ' [$errorType]' : '';
final errorMsgStr = errorMessage != null ? ' $errorMessage' : '';
final extraStr = extra != null && extra!.isNotEmpty ? ' $extra' : '';
return '$ts ${level.name.toUpperCase().padRight(7)} [$module$locationStr]$errorStr $message$errorMsgStr$extraStr';
}
String toFileString() {
final sb = StringBuffer();
sb.writeln('[$timestamp] ${level.name.toUpperCase()} [$module]');
if (funcName != null || lineNo != null) {
sb.write(' at ${funcName ?? ''}');
if (lineNo != null) sb.write(':$lineNo');
sb.writeln();
}
sb.writeln(' $message');
if (errorType != null) {
sb.writeln(' Error: $errorType');
}
if (errorMessage != null) {
sb.writeln(' ErrorMessage: $errorMessage');
}
if (stackTrace != null) {
sb.writeln(' StackTrace:');
sb.writeln(stackTrace);
}
if (extra != null && extra!.isNotEmpty) {
sb.writeln(' Extra: $extra');
}
return sb.toString();
}
}
@@ -0,0 +1,36 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class LogFileHandler {
File? _file;
IOSink? _sink;
Future<void> init(String logDir, String logFileName) async {
final dir = await getApplicationDocumentsDirectory();
final logPath = '${dir.path}/$logDir';
await Directory(logPath).create(recursive: true);
_file = File('$logPath/$logFileName');
_sink = _file!.openWrite(mode: FileMode.append);
}
void write(String content) {
_sink?.writeln(content);
}
Future<void> flush() async {
await _sink?.flush();
}
Future<void> close() async {
await _sink?.close();
_sink = null;
_file = null;
}
Future<List<String>> readAllLines() async {
if (_file == null || !await _file!.exists()) return [];
return await _file!.readAsLines();
}
String? get filePath => _file?.path;
}
+172
View File
@@ -0,0 +1,172 @@
import 'package:flutter/foundation.dart';
import 'log_config.dart';
import 'log_entry.dart';
import 'log_file_handler.dart';
class LogService {
final LogConfig _config;
LogFileHandler? _fileHandler;
final _buffer = <String>[];
static const _maxBufferSize = 50;
LogService._({required LogConfig config}) : _config = config;
static Future<LogService> create({LogConfig? config}) async {
final isRelease = kReleaseMode;
final effectiveConfig =
config ?? (isRelease ? LogConfig.forRelease() : LogConfig.forDebug());
final service = LogService._(config: effectiveConfig);
if (effectiveConfig.output == LogOutput.file) {
service._fileHandler = LogFileHandler();
await service._fileHandler!.init(
effectiveConfig.logDir,
effectiveConfig.logFileName,
);
}
return service;
}
String? get logFilePath => _fileHandler?.filePath;
void _log(LogEntry entry) {
if (entry.level.index < _config.minLevel.index) return;
if (_config.output == LogOutput.console) {
debugPrint(entry.toConsoleString());
if (entry.stackTrace != null) {
debugPrint(entry.stackTrace!);
}
} else {
_buffer.add(entry.toFileString());
if (_buffer.length >= _maxBufferSize) {
_flushBuffer();
}
}
}
void _flushBuffer() {
for (final line in _buffer) {
_fileHandler?.write(line);
}
_buffer.clear();
_fileHandler?.flush();
}
(String?, int?) _extractLocation(StackTrace stackTrace) {
final frames = stackTrace.toString().split('\n');
for (final frame in frames) {
if (frame.contains('.dart')) {
final match = RegExp(
r'#\d+\s+(.+?)\s+\((.+?):(\d+)\)',
).firstMatch(frame);
if (match != null) {
return (match.group(1), int.tryParse(match.group(3) ?? ''));
}
}
}
return (null, null);
}
void debug({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.debug,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void info({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.info,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void warning({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.warning,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
),
);
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
required String module,
Map<String, dynamic>? extra,
}) {
final (funcName, lineNo) = _extractLocation(stackTrace);
_log(
LogEntry(
timestamp: DateTime.now(),
level: LogLevel.error,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
errorType: error.runtimeType.toString(),
errorMessage: error.toString(),
stackTrace: stackTrace.toString(),
extra: extra,
),
);
}
void flush() {
_flushBuffer();
_fileHandler?.flush();
}
Future<List<String>> readLogs() async {
return await _fileHandler?.readAllLines() ?? [];
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/foundation.dart';
import 'log_entry.dart';
import 'log_service.dart';
LogService? _globalLogService;
class Logger {
final String module;
final LogService? _service;
final bool _isNoOp;
Logger(this.module, this._service) : _isNoOp = _service == null;
factory Logger.get(String module) {
return Logger(module, _globalLogService);
}
static void setLogService(LogService service) {
_globalLogService = service;
}
void debug({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.debug(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void info({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.info(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void warning({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
if (_isNoOp) return;
_service!.warning(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
Map<String, dynamic>? extra,
}) {
final entry = LogEntry(
timestamp: DateTime.now(),
level: LogLevel.error,
message: message,
module: module,
errorType: error.runtimeType.toString(),
errorMessage: error.toString(),
stackTrace: stackTrace.toString(),
extra: extra,
);
if (_isNoOp) {
debugPrint(entry.toConsoleString());
return;
}
_service!.error(
message: message,
error: error,
stackTrace: stackTrace,
module: module,
extra: extra,
);
}
}
Logger getLogger(String module) => Logger.get(module);
+22
View File
@@ -0,0 +1,22 @@
class ApiProblem implements Exception {
ApiProblem({
required this.status,
required this.title,
required this.detail,
this.code,
});
final int status;
final String title;
final String detail;
final String? code;
String toUserMessage() {
return 'Request failed';
}
@override
String toString() {
return toUserMessage();
}
}
@@ -0,0 +1,28 @@
import '../../l10n/app_localizations.dart';
import 'api_problem.dart';
String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) {
switch (problem.code) {
case 'AUTH_TOO_MANY_REQUESTS':
return l10n.errorTooManyRequests;
case 'AUTH_VERIFICATION_CODE_INVALID':
return l10n.errorInvalidVerificationCode;
case 'AUTH_REFRESH_TOKEN_INVALID':
return l10n.errorSessionExpired;
case 'AUTH_SERVICE_UNAVAILABLE':
return l10n.errorServiceUnavailable;
case 'POINTS_INSUFFICIENT_BALANCE':
return l10n.toastCoinInsufficient;
case 'AGENT_SESSION_RUN_LIMIT_EXCEEDED':
return l10n.errorRunLimitExceeded;
case 'AGENT_DIVINATION_PAYLOAD_REQUIRED':
return l10n.errorDivinationPayloadRequired;
default:
break;
}
if (problem.status >= 500) {
return l10n.errorServerGeneric;
}
return l10n.errorRequestGeneric;
}
+128
View File
@@ -0,0 +1,128 @@
import 'package:dio/dio.dart';
import '../../core/logging/logger.dart';
import '../../core/network/api_problem.dart';
class ApiClient {
ApiClient({
required String baseUrl,
Future<String?> Function()? tokenProvider,
Future<void> Function()? onUnauthorized,
}) : _dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: const {'Content-Type': 'application/json'},
),
) {
if (tokenProvider != null) {
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await tokenProvider();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
final status = error.response?.statusCode;
final authHeader =
error.requestOptions.headers['Authorization'] as String?;
final hasAuthHeader = authHeader != null && authHeader.isNotEmpty;
if (status == 401 && hasAuthHeader && onUnauthorized != null) {
await onUnauthorized();
}
handler.next(error);
},
),
);
}
}
final Dio _dio;
final Logger _logger = getLogger('data.network.api_client');
Dio get rawDio => _dio;
Future<void> postNoContent(String path, {Map<String, dynamic>? data}) async {
try {
await _dio.post<void>(path, data: data);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'POST no-content failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<void> deleteNoContent(
String path, {
Map<String, dynamic>? data,
}) async {
try {
await _dio.delete<void>(path, data: data);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'DELETE no-content failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<Map<String, dynamic>> postJson(
String path, {
Map<String, dynamic>? data,
}) async {
try {
final response = await _dio.post<Map<String, dynamic>>(path, data: data);
return response.data ?? <String, dynamic>{};
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'POST json failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<Map<String, dynamic>> getJson(String path) async {
try {
final response = await _dio.get<Map<String, dynamic>>(path);
return response.data ?? <String, dynamic>{};
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'GET json failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
ApiProblem _mapProblem(DioException error) {
final status = error.response?.statusCode ?? 500;
final data = error.response?.data;
if (data is Map<String, dynamic>) {
return ApiProblem(
status: status,
title: (data['title'] as String?) ?? 'Request failed',
detail: (data['detail'] as String?) ?? '',
code: data['code'] as String?,
);
}
return ApiProblem(
status: status,
title: 'Network error',
detail: error.message ?? 'Request failed',
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:shared_preferences/shared_preferences.dart';
class LocalKvStore {
Future<SharedPreferences> get _prefs async {
return SharedPreferences.getInstance();
}
Future<void> setString(String key, String value) async {
final prefs = await _prefs;
await prefs.setString(key, value);
}
Future<String?> getString(String key) async {
final prefs = await _prefs;
return prefs.getString(key);
}
Future<void> setBool(String key, bool value) async {
final prefs = await _prefs;
await prefs.setBool(key, value);
}
Future<bool> getBool(String key, {bool fallback = false}) async {
final prefs = await _prefs;
return prefs.getBool(key) ?? fallback;
}
Future<void> remove(String key) async {
final prefs = await _prefs;
await prefs.remove(key);
}
}
@@ -0,0 +1,41 @@
import '../../../../data/network/api_client.dart';
import '../models/session_response.dart';
class AuthApi {
AuthApi({required ApiClient apiClient}) : _apiClient = apiClient;
final ApiClient _apiClient;
Future<void> sendOtp({required String email}) async {
await _apiClient.postNoContent(
'/api/v1/auth/otp/send',
data: {'email': email},
);
}
Future<SessionResponse> createEmailSession({
required String email,
required String token,
}) async {
final json = await _apiClient.postJson(
'/api/v1/auth/email-session',
data: {'email': email, 'token': token},
);
return SessionResponse.fromJson(json);
}
Future<void> deleteSession({required String refreshToken}) async {
await _apiClient.deleteNoContent(
'/api/v1/auth/sessions',
data: {'refresh_token': refreshToken},
);
}
Future<SessionResponse> refreshSession({required String refreshToken}) async {
final json = await _apiClient.postJson(
'/api/v1/auth/sessions/refresh',
data: {'refresh_token': refreshToken},
);
return SessionResponse.fromJson(json);
}
}
@@ -0,0 +1,6 @@
class AuthUser {
const AuthUser({required this.id, required this.email});
final String id;
final String email;
}
@@ -0,0 +1,45 @@
class SessionResponse {
SessionResponse({
required this.accessToken,
required this.refreshToken,
required this.expiresIn,
required this.tokenType,
required this.userId,
required this.userEmail,
});
final String accessToken;
final String refreshToken;
final int expiresIn;
final String tokenType;
final String userId;
final String userEmail;
factory SessionResponse.fromJson(Map<String, dynamic> json) {
final user = (json['user'] as Map<String, dynamic>?) ?? <String, dynamic>{};
final accessToken = json['access_token'] as String?;
final refreshToken = json['refresh_token'] as String?;
final expiresIn = json['expires_in'] as int?;
final tokenType = json['token_type'] as String?;
final userId = user['id'] as String?;
final userEmail = user['email'] as String?;
if (accessToken == null ||
refreshToken == null ||
expiresIn == null ||
tokenType == null ||
userId == null ||
userEmail == null) {
throw const FormatException('Invalid session response payload');
}
return SessionResponse(
accessToken: accessToken,
refreshToken: refreshToken,
expiresIn: expiresIn,
tokenType: tokenType,
userId: userId,
userEmail: userEmail,
);
}
}
@@ -0,0 +1,86 @@
import '../../../../core/auth/session_store.dart';
import '../apis/auth_api.dart';
import '../models/auth_user.dart';
abstract class AuthRepository {
Future<void> sendOtp(String email);
Future<AuthUser> loginWithEmailOtp({
required String email,
required String otp,
});
Future<AuthUser?> recoverSession();
Future<void> logout();
Future<void> clearLocalSession();
}
class AuthRepositoryImpl implements AuthRepository {
AuthRepositoryImpl({
required AuthApi authApi,
required SessionStore sessionStore,
}) : _authApi = authApi,
_sessionStore = sessionStore;
final AuthApi _authApi;
final SessionStore _sessionStore;
@override
Future<void> sendOtp(String email) async {
await _authApi.sendOtp(email: email);
}
@override
Future<AuthUser> loginWithEmailOtp({
required String email,
required String otp,
}) async {
final session = await _authApi.createEmailSession(email: email, token: otp);
await _sessionStore.saveToken(session.accessToken);
await _sessionStore.saveRefreshToken(session.refreshToken);
await _sessionStore.saveEmail(email);
return AuthUser(id: session.userId, email: email);
}
@override
Future<AuthUser?> recoverSession() async {
final refreshToken = await _sessionStore.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
return null;
}
final session = await _authApi.refreshSession(refreshToken: refreshToken);
await _sessionStore.saveToken(session.accessToken);
await _sessionStore.saveRefreshToken(session.refreshToken);
final savedEmail = await _sessionStore.getEmail();
final email = savedEmail?.isNotEmpty == true
? savedEmail!
: session.userEmail;
if (email.isNotEmpty) {
await _sessionStore.saveEmail(email);
}
return AuthUser(id: session.userId, email: email);
}
@override
Future<void> logout() async {
try {
final refreshToken = await _sessionStore.getRefreshToken();
if (refreshToken != null && refreshToken.isNotEmpty) {
await _authApi.deleteSession(refreshToken: refreshToken);
}
} finally {
await clearLocalSession();
}
}
@override
Future<void> clearLocalSession() async {
await _sessionStore.clearToken();
await _sessionStore.clearRefreshToken();
await _sessionStore.clearEmail();
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/foundation.dart';
import '../../../../core/logging/logger.dart';
import '../../data/repositories/auth_repository.dart';
import 'auth_state.dart';
class AuthBloc extends ChangeNotifier {
AuthBloc({required AuthRepository repository}) : _repository = repository;
final AuthRepository _repository;
final Logger _logger = getLogger('features.auth.bloc');
AuthState _state = AuthState.initial;
bool _handlingUnauthorized = false;
AuthState get state => _state;
Future<void> start() async {
_state = _state.copyWith(status: AuthStatus.loading, errorMessage: null);
notifyListeners();
try {
final user = await _repository.recoverSession();
if (user == null) {
_state = const AuthState(status: AuthStatus.unauthenticated);
} else {
_state = AuthState(status: AuthStatus.authenticated, user: user);
}
notifyListeners();
} catch (error, stackTrace) {
_logger.error(
message: 'Session recovery failed: ${error.runtimeType}',
error: error.runtimeType.toString(),
stackTrace: stackTrace,
);
await _repository.clearLocalSession();
_state = AuthState(
status: AuthStatus.unauthenticated,
errorMessage: _toSafeMessage(error),
);
notifyListeners();
}
}
Future<void> sendOtp(String email) async {
await _repository.sendOtp(email);
}
Future<void> loginWithOtp({
required String email,
required String otp,
}) async {
final user = await _repository.loginWithEmailOtp(email: email, otp: otp);
_logger.info(message: 'User logged in', extra: {'user_id': user.id});
_state = AuthState(status: AuthStatus.authenticated, user: user);
notifyListeners();
}
Future<void> logout() async {
try {
await _repository.logout();
} catch (error, stackTrace) {
_logger.error(
message: 'User logout failed: ${error.runtimeType}',
error: error.runtimeType.toString(),
stackTrace: stackTrace,
);
}
_logger.info(message: 'User logged out');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
}
Future<void> handleUnauthorized401() async {
if (_handlingUnauthorized) {
return;
}
_handlingUnauthorized = true;
try {
await _repository.clearLocalSession();
_logger.warning(message: 'Session invalidated by 401 callback');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
} finally {
_handlingUnauthorized = false;
}
}
String _toSafeMessage(Object error) {
return 'Request failed, please try again';
}
}
@@ -0,0 +1,25 @@
import '../../data/models/auth_user.dart';
enum AuthStatus { initial, loading, authenticated, unauthenticated }
class AuthState {
const AuthState({required this.status, this.user, this.errorMessage});
final AuthStatus status;
final AuthUser? user;
final String? errorMessage;
AuthState copyWith({
AuthStatus? status,
AuthUser? user,
String? errorMessage,
}) {
return AuthState(
status: status ?? this.status,
user: user ?? this.user,
errorMessage: errorMessage,
);
}
static const AuthState initial = AuthState(status: AuthStatus.initial);
}
@@ -0,0 +1,500 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../settings/presentation/models/legal_document_type.dart';
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';
class LoginScreen extends StatefulWidget {
const LoginScreen({
super.key,
required this.onRequestOtp,
required this.onLoginWithOtp,
required this.onLocaleChanged,
required this.currentLocale,
});
final Future<void> Function(String email) onRequestOtp;
final Future<void> Function(String email, String otp) onLoginWithOtp;
final ValueChanged<Locale> onLocaleChanged;
final Locale currentLocale;
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final Logger _logger = getLogger('features.auth.login');
final TextEditingController _emailController = TextEditingController();
final TextEditingController _codeController = TextEditingController();
Timer? _timer;
int _countdown = 0;
bool _isSending = false;
bool _agreementChecked = false;
bool get _isValidEmail {
return RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
).hasMatch(_emailController.text.trim());
}
@override
void dispose() {
_timer?.cancel();
_emailController.dispose();
_codeController.dispose();
super.dispose();
}
void _showMessage(String message) {
Toast.show(context, message, type: ToastType.info);
}
Future<void> _sendCode() async {
final l10n = AppLocalizations.of(context)!;
if (!_isValidEmail) {
_showMessage(l10n.invalidEmail);
return;
}
if (_countdown > 0 || _isSending) {
return;
}
setState(() {
_isSending = true;
});
try {
await widget.onRequestOtp(_emailController.text.trim());
if (!mounted) {
return;
}
setState(() {
_isSending = false;
_countdown = 60;
});
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
if (_countdown <= 1) {
timer.cancel();
setState(() {
_countdown = 0;
});
} else {
setState(() {
_countdown -= 1;
});
}
});
} catch (error, stackTrace) {
_logger.error(
message: 'Send OTP failed',
error: error,
stackTrace: stackTrace,
);
if (!mounted) {
return;
}
setState(() {
_isSending = false;
});
_showMessage(_safeErrorMessage(error));
}
}
Future<void> _login() async {
final l10n = AppLocalizations.of(context)!;
if (!_isValidEmail) {
_showMessage(l10n.invalidEmail);
return;
}
if (_codeController.text.length != 6) {
_showMessage(l10n.invalidCode);
return;
}
if (!_agreementChecked) {
_showMessage(l10n.agreementRequired);
return;
}
try {
await widget.onLoginWithOtp(
_emailController.text.trim(),
_codeController.text,
);
if (!mounted) {
return;
}
} catch (error, stackTrace) {
_logger.error(
message: 'Login with OTP failed',
error: error,
stackTrace: stackTrace,
);
_showMessage(_safeErrorMessage(error));
}
}
String _safeErrorMessage(Object error) {
final l10n = AppLocalizations.of(context)!;
if (error is ApiProblem) {
return mapApiProblemToMessage(error, l10n);
}
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<void>(
context: context,
builder: (dialogContext) {
return AppModalDialog(
title: title,
message: content,
icon: Icons.description_outlined,
actions: [
AppModalDialogAction(
label: AppLocalizations.of(dialogContext)!.dialogConfirm,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
);
},
);
}
Future<void> _openLegalDocument(LegalDocumentType type) async {
final l10n = AppLocalizations.of(context)!;
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => LegalDocumentScreen(
title: legalDocumentTitle(l10n, type),
assetPath: legalDocumentAssetPath(
Localizations.localeOf(context),
type,
),
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final canLogin =
_isValidEmail && _codeController.text.length == 6 && _agreementChecked;
return Scaffold(
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,
),
),
],
),
),
),
),
],
),
],
),
),
);
},
),
),
),
],
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More