diff --git a/.env.example b/.env.example index 923fc96..1b6a568 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ ERYAO_RUNTIME__TRUSTED_PROXY_IPS=[] # Web 服务器配置(Uvicorn) ############ ERYAO_WEB__HOST=0.0.0.0 -ERYAO_WEB__PORT=8000 +ERYAO_WEB__PORT=5775 ERYAO_WEB__WORKERS=2 ############ @@ -79,5 +79,5 @@ ERYAO_CORS__ALLOW_ORIGINS=["http://localhost", "http://localhost:3000"] ############ # Test相关 ############ -ERYAO_TEST__PHONE=8613812345678 +ERYAO_TEST__EMAIL=8613812345678 ERYAO_TEST__PASSWORD=Test@123456 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..38cbae7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +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-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/ + + - id: apps-flutter-test + name: apps flutter test + entry: bash -lc 'cd apps && flutter test' + language: system + pass_filenames: false + files: ^apps/ diff --git a/apps/AGENTS.md b/apps/AGENTS.md index f0f65c4..8f8bf8d 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -57,6 +57,7 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. - User feedback: `Toast` / `AppBanner` only. - Loading indicators: `AppLoadingIndicator` only. - 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) diff --git a/apps/README.md b/apps/README.md index a5412d8..a032a7f 100644 --- a/apps/README.md +++ b/apps/README.md @@ -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) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +### Direct command -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +```bash +flutter run --dart-define=BACKEND_URL=http://192.168.1.100:5775 +``` + +### 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` diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 0457f73..e55cb93 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -6,7 +8,7 @@ plugins { } android { - namespace = "com.meeyao.meeyao_qianwen" + namespace = "com.meeyao.qianwen" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -20,8 +22,7 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.meeyao.meeyao_qianwen" + applicationId = "com.meeyao.qianwen" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -30,11 +31,30 @@ android { 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 { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 8683223..d530288 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,11 @@ + android:icon="@mipmap/ic_launcher" + android:allowBackup="true" + android:fullBackupContent="@xml/backup_rules" + android:dataExtractionRules="@xml/data_extraction_rules"> + + + + + + diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..9417b56 100644 Binary files a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..ab80954 100644 Binary files a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..df4b232 100644 Binary files a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..842dfa6 100644 Binary files a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..8d76863 100644 Binary files a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/values-en/strings.xml b/apps/android/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..3ffd247 --- /dev/null +++ b/apps/android/app/src/main/res/values-en/strings.xml @@ -0,0 +1,4 @@ + + + MeiYao Divination + diff --git a/apps/android/app/src/main/res/values/colors.xml b/apps/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/apps/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..eca6d3a --- /dev/null +++ b/apps/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 觅爻签问 + diff --git a/apps/android/app/src/main/res/xml/backup_rules.xml b/apps/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..5eab9ca --- /dev/null +++ b/apps/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apps/android/app/src/main/res/xml/data_extraction_rules.xml b/apps/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..eba5cf8 --- /dev/null +++ b/apps/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/android/key.properties.example b/apps/android/key.properties.example new file mode 100644 index 0000000..4c41cd2 --- /dev/null +++ b/apps/android/key.properties.example @@ -0,0 +1,4 @@ +storePassword=your_store_password +keyPassword=your_key_password +keyAlias=upload +storeFile=release.jks diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..d0d98aa 100644 --- a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" : "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" - } -} +{"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"}} \ No newline at end of file diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..004fe54 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..9affddc 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..c6758d4 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..d95c07b 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..49ea837 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..2465496 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..8c60233 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..c6758d4 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..6547b1f 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..398f976 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..7019311 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..bb1740b Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..69e3b7b Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..1b792e3 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..398f976 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..a3b81a1 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..9417b56 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..842dfa6 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..de29e15 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..2d8d5a8 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..6d1c591 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart new file mode 100644 index 0000000..5ec6860 --- /dev/null +++ b/apps/lib/app/app.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import '../core/auth/session_store.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/home/presentation/screens/home_screen.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 createState() => _EryaoAppState(); +} + +class _EryaoAppState extends State { + final SessionStore _sessionStore = SessionStore(LocalKvStore()); + late final AuthBloc _authBloc; + Locale _locale = const Locale('zh'); + + @override + void initState() { + super.initState(); + final apiClient = ApiClient( + baseUrl: appDependencies.backendUrl, + tokenProvider: _sessionStore.getToken, + onUnauthorized: () { + return _authBloc.handleUnauthorized401(); + }, + ); + final authApi = AuthApi(apiClient: apiClient); + final authRepository = AuthRepositoryImpl( + authApi: authApi, + sessionStore: _sessionStore, + ); + _authBloc = AuthBloc(repository: authRepository); + _bootstrap(); + } + + @override + void dispose() { + _authBloc.dispose(); + super.dispose(); + } + + Future _bootstrap() async { + final localeCode = await _sessionStore.getLocaleCode(); + if (mounted) { + setState(() { + _locale = localeCode == 'en' ? const Locale('en') : const Locale('zh'); + }); + } + await _authBloc.start(); + } + + Future _handleLocaleChanged(Locale locale) async { + await _sessionStore.saveLocaleCode(locale.languageCode); + if (!mounted) { + return; + } + setState(() { + _locale = locale; + }); + } + + @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) { + return HomeScreen( + account: state.user!.email, + sessionStore: _sessionStore, + onLogout: _authBloc.logout, + ); + } + + return LoginScreen( + currentLocale: _locale, + onLocaleChanged: _handleLocaleChanged, + onRequestOtp: _authBloc.sendOtp, + onLoginWithOtp: (email, otp) { + return _authBloc.loginWithOtp(email: email, otp: otp); + }, + ); + } +} diff --git a/apps/lib/app/app_theme.dart b/apps/lib/app/app_theme.dart new file mode 100644 index 0000000..f6d0088 --- /dev/null +++ b/apps/lib/app/app_theme.dart @@ -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 >[ + 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), + ), + ], + ); + } +} diff --git a/apps/lib/app/di/injection.dart b/apps/lib/app/di/injection.dart new file mode 100644 index 0000000..00f16de --- /dev/null +++ b/apps/lib/app/di/injection.dart @@ -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 configureDependencies() async { + _appDependencies = AppDependencies(backendUrl: Env.backendUrl); +} diff --git a/apps/lib/core/auth/session_store.dart b/apps/lib/core/auth/session_store.dart new file mode 100644 index 0000000..1e67523 --- /dev/null +++ b/apps/lib/core/auth/session_store.dart @@ -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 saveToken(String token) async { + await _secureStorage.write(key: _tokenKey, value: token); + } + + Future getToken() async { + return _secureStorage.read(key: _tokenKey); + } + + Future clearToken() async { + await _secureStorage.delete(key: _tokenKey); + } + + Future saveRefreshToken(String refreshToken) async { + await _secureStorage.write(key: _refreshTokenKey, value: refreshToken); + } + + Future getRefreshToken() async { + return _secureStorage.read(key: _refreshTokenKey); + } + + Future clearRefreshToken() async { + await _secureStorage.delete(key: _refreshTokenKey); + } + + Future saveEmail(String email) async { + await _secureStorage.write(key: _emailKey, value: email); + } + + Future getEmail() async { + return _secureStorage.read(key: _emailKey); + } + + Future clearEmail() async { + await _secureStorage.delete(key: _emailKey); + } + + Future setWelcomeRead(bool value) async { + await _kvStore.setBool(_welcomeReadKey, value); + } + + Future hasReadWelcome() async { + return _kvStore.getBool(_welcomeReadKey); + } + + Future saveLocaleCode(String localeCode) async { + await _kvStore.setString(_localeKey, localeCode); + } + + Future getLocaleCode() async { + return _kvStore.getString(_localeKey); + } +} diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart new file mode 100644 index 0000000..0bcfe22 --- /dev/null +++ b/apps/lib/core/config/env.dart @@ -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 init() async {} +} diff --git a/apps/lib/core/logging/error_handler.dart b/apps/lib/core/logging/error_handler.dart new file mode 100644 index 0000000..85ca334 --- /dev/null +++ b/apps/lib/core/logging/error_handler.dart @@ -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); + }; + } +} diff --git a/apps/lib/core/logging/log_config.dart b/apps/lib/core/logging/log_config.dart new file mode 100644 index 0000000..f5a46e4 --- /dev/null +++ b/apps/lib/core/logging/log_config.dart @@ -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', + ); +} diff --git a/apps/lib/core/logging/log_entry.dart b/apps/lib/core/logging/log_entry.dart new file mode 100644 index 0000000..1f9e734 --- /dev/null +++ b/apps/lib/core/logging/log_entry.dart @@ -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? 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 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(); + } +} diff --git a/apps/lib/core/logging/log_file_handler.dart b/apps/lib/core/logging/log_file_handler.dart new file mode 100644 index 0000000..a9a0aa3 --- /dev/null +++ b/apps/lib/core/logging/log_file_handler.dart @@ -0,0 +1,36 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; + +class LogFileHandler { + File? _file; + IOSink? _sink; + + Future 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 flush() async { + await _sink?.flush(); + } + + Future close() async { + await _sink?.close(); + _sink = null; + _file = null; + } + + Future> readAllLines() async { + if (_file == null || !await _file!.exists()) return []; + return await _file!.readAsLines(); + } + + String? get filePath => _file?.path; +} diff --git a/apps/lib/core/logging/log_service.dart b/apps/lib/core/logging/log_service.dart new file mode 100644 index 0000000..4566c50 --- /dev/null +++ b/apps/lib/core/logging/log_service.dart @@ -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 = []; + static const _maxBufferSize = 50; + + LogService._({required LogConfig config}) : _config = config; + + static Future 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? 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? 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? 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? 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> readLogs() async { + return await _fileHandler?.readAllLines() ?? []; + } +} diff --git a/apps/lib/core/logging/logger.dart b/apps/lib/core/logging/logger.dart new file mode 100644 index 0000000..453c360 --- /dev/null +++ b/apps/lib/core/logging/logger.dart @@ -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? extra, + StackTrace? stackTrace, + }) { + if (_isNoOp) return; + _service!.debug( + message: message, + module: module, + extra: extra ?? {}, + stackTrace: stackTrace, + ); + } + + void info({ + required String message, + Map? extra, + StackTrace? stackTrace, + }) { + if (_isNoOp) return; + _service!.info( + message: message, + module: module, + extra: extra ?? {}, + stackTrace: stackTrace, + ); + } + + void warning({ + required String message, + Map? 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? 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); diff --git a/apps/lib/core/network/api_problem.dart b/apps/lib/core/network/api_problem.dart new file mode 100644 index 0000000..7840967 --- /dev/null +++ b/apps/lib/core/network/api_problem.dart @@ -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(); + } +} diff --git a/apps/lib/core/network/api_problem_mapper.dart b/apps/lib/core/network/api_problem_mapper.dart new file mode 100644 index 0000000..ba623ee --- /dev/null +++ b/apps/lib/core/network/api_problem_mapper.dart @@ -0,0 +1,22 @@ +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; + default: + break; + } + + if (problem.status >= 500) { + return l10n.errorServerGeneric; + } + return l10n.errorRequestGeneric; +} diff --git a/apps/lib/data/network/api_client.dart b/apps/lib/data/network/api_client.dart new file mode 100644 index 0000000..1e78338 --- /dev/null +++ b/apps/lib/data/network/api_client.dart @@ -0,0 +1,112 @@ +import 'package:dio/dio.dart'; + +import '../../core/logging/logger.dart'; +import '../../core/network/api_problem.dart'; + +class ApiClient { + ApiClient({ + required String baseUrl, + Future Function()? tokenProvider, + Future 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'); + + Future postNoContent(String path, {Map? data}) async { + try { + await _dio.post(path, data: data); + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'POST no-content failed', + error: error, + stackTrace: stackTrace, + ); + throw _mapProblem(error); + } + } + + Future deleteNoContent( + String path, { + Map? data, + }) async { + try { + await _dio.delete(path, data: data); + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'DELETE no-content failed', + error: error, + stackTrace: stackTrace, + ); + throw _mapProblem(error); + } + } + + Future> postJson( + String path, { + Map? data, + }) async { + try { + final response = await _dio.post>(path, data: data); + return response.data ?? {}; + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'POST 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) { + 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', + ); + } +} diff --git a/apps/lib/data/storage/local_kv_store.dart b/apps/lib/data/storage/local_kv_store.dart new file mode 100644 index 0000000..1954000 --- /dev/null +++ b/apps/lib/data/storage/local_kv_store.dart @@ -0,0 +1,32 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class LocalKvStore { + Future get _prefs async { + return SharedPreferences.getInstance(); + } + + Future setString(String key, String value) async { + final prefs = await _prefs; + await prefs.setString(key, value); + } + + Future getString(String key) async { + final prefs = await _prefs; + return prefs.getString(key); + } + + Future setBool(String key, bool value) async { + final prefs = await _prefs; + await prefs.setBool(key, value); + } + + Future getBool(String key, {bool fallback = false}) async { + final prefs = await _prefs; + return prefs.getBool(key) ?? fallback; + } + + Future remove(String key) async { + final prefs = await _prefs; + await prefs.remove(key); + } +} diff --git a/apps/lib/features/auth/data/apis/auth_api.dart b/apps/lib/features/auth/data/apis/auth_api.dart new file mode 100644 index 0000000..769a86d --- /dev/null +++ b/apps/lib/features/auth/data/apis/auth_api.dart @@ -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 sendOtp({required String email}) async { + await _apiClient.postNoContent( + '/api/v1/auth/otp/send', + data: {'email': email}, + ); + } + + Future 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 deleteSession({required String refreshToken}) async { + await _apiClient.deleteNoContent( + '/api/v1/auth/sessions', + data: {'refresh_token': refreshToken}, + ); + } + + Future refreshSession({required String refreshToken}) async { + final json = await _apiClient.postJson( + '/api/v1/auth/sessions/refresh', + data: {'refresh_token': refreshToken}, + ); + return SessionResponse.fromJson(json); + } +} diff --git a/apps/lib/features/auth/data/models/auth_user.dart b/apps/lib/features/auth/data/models/auth_user.dart new file mode 100644 index 0000000..6d63c8b --- /dev/null +++ b/apps/lib/features/auth/data/models/auth_user.dart @@ -0,0 +1,6 @@ +class AuthUser { + const AuthUser({required this.id, required this.email}); + + final String id; + final String email; +} diff --git a/apps/lib/features/auth/data/models/session_response.dart b/apps/lib/features/auth/data/models/session_response.dart new file mode 100644 index 0000000..4771664 --- /dev/null +++ b/apps/lib/features/auth/data/models/session_response.dart @@ -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 json) { + final user = (json['user'] as Map?) ?? {}; + 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, + ); + } +} diff --git a/apps/lib/features/auth/data/repositories/auth_repository.dart b/apps/lib/features/auth/data/repositories/auth_repository.dart new file mode 100644 index 0000000..a46fd06 --- /dev/null +++ b/apps/lib/features/auth/data/repositories/auth_repository.dart @@ -0,0 +1,86 @@ +import '../../../../core/auth/session_store.dart'; +import '../apis/auth_api.dart'; +import '../models/auth_user.dart'; + +abstract class AuthRepository { + Future sendOtp(String email); + + Future loginWithEmailOtp({ + required String email, + required String otp, + }); + + Future recoverSession(); + + Future logout(); + + Future clearLocalSession(); +} + +class AuthRepositoryImpl implements AuthRepository { + AuthRepositoryImpl({ + required AuthApi authApi, + required SessionStore sessionStore, + }) : _authApi = authApi, + _sessionStore = sessionStore; + + final AuthApi _authApi; + final SessionStore _sessionStore; + + @override + Future sendOtp(String email) async { + await _authApi.sendOtp(email: email); + } + + @override + Future 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 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 logout() async { + final refreshToken = await _sessionStore.getRefreshToken(); + try { + if (refreshToken != null && refreshToken.isNotEmpty) { + await _authApi.deleteSession(refreshToken: refreshToken); + } + } finally { + await clearLocalSession(); + } + } + + @override + Future clearLocalSession() async { + await _sessionStore.clearToken(); + await _sessionStore.clearRefreshToken(); + await _sessionStore.clearEmail(); + } +} diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart new file mode 100644 index 0000000..ba9a72c --- /dev/null +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -0,0 +1,98 @@ +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 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: error, + stackTrace: stackTrace, + ); + await _repository.clearLocalSession(); + _state = AuthState( + status: AuthStatus.unauthenticated, + errorMessage: _toSafeMessage(error), + ); + notifyListeners(); + } + } + + Future sendOtp(String email) async { + await _repository.sendOtp(email); + } + + Future 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 logout() async { + Object? caughtError; + StackTrace? caughtStackTrace; + try { + await _repository.logout(); + } catch (error, stackTrace) { + caughtError = error; + caughtStackTrace = stackTrace; + _logger.error( + message: 'User logout failed', + error: error, + stackTrace: stackTrace, + ); + } + _logger.info(message: 'User logged out'); + _state = const AuthState(status: AuthStatus.unauthenticated); + notifyListeners(); + if (caughtError != null) { + Error.throwWithStackTrace(caughtError, caughtStackTrace!); + } + } + + Future 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'; + } +} diff --git a/apps/lib/features/auth/presentation/bloc/auth_state.dart b/apps/lib/features/auth/presentation/bloc/auth_state.dart new file mode 100644 index 0000000..bd710b3 --- /dev/null +++ b/apps/lib/features/auth/presentation/bloc/auth_state.dart @@ -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); +} diff --git a/apps/lib/features/auth/presentation/screens/login_screen.dart b/apps/lib/features/auth/presentation/screens/login_screen.dart new file mode 100644 index 0000000..8dfd415 --- /dev/null +++ b/apps/lib/features/auth/presentation/screens/login_screen.dart @@ -0,0 +1,388 @@ +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 '../../../../l10n/app_localizations.dart'; +import '../../../../shared/theme/design_tokens.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 Function(String email) onRequestOtp; + final Future Function(String email, String otp) onLoginWithOtp; + final ValueChanged onLocaleChanged; + final Locale currentLocale; + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + 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 _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 _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; + } + + void _showPolicyDialog(String title, String content) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.dialogConfirm), + ), + ], + ); + }, + ); + } + + @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( + 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.xxxl), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + 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, + ), + ], + ), + ), + PopupMenuButton( + icon: Icon(Icons.language, color: colors.primary), + onSelected: widget.onLocaleChanged, + itemBuilder: (context) => [ + PopupMenuItem( + value: const Locale('zh'), + child: Text(l10n.chinese), + ), + PopupMenuItem( + value: const Locale('en'), + child: Text(l10n.english), + ), + ], + ), + ], + ), + const SizedBox(height: AppSpacing.xxl), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: l10n.emailHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + counterText: '', + hintText: l10n.codeHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + ), + ), + 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.sm), + ), + ), + 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.sm), + ), + ), + onPressed: canLogin ? _login : null, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, + ), + 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 = () => _showPolicyDialog( + l10n.privacyPolicy, + l10n.privacyContent, + ), + ), + TextSpan(text: l10n.agreementSeparator), + TextSpan( + text: l10n.termsOfService, + style: TextStyle( + color: colors.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => _showPolicyDialog( + l10n.termsOfService, + l10n.termsContent, + ), + ), + 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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart new file mode 100644 index 0000000..808731c --- /dev/null +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -0,0 +1,559 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/auth/session_store.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../shared/theme/app_color_palette.dart'; +import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/bottom_nav_bar.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({ + super.key, + required this.account, + required this.sessionStore, + required this.onLogout, + }); + + final String account; + final SessionStore sessionStore; + final Future Function() onLogout; + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + bool _showNotificationDot = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _tryShowWelcomeDialog(); + }); + } + + Future _tryShowWelcomeDialog() async { + final hasRead = await widget.sessionStore.hasReadWelcome(); + if (hasRead || !mounted) { + return; + } + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return _WelcomeDialog( + onDone: () async { + await widget.sessionStore.setWelcomeRead(true); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final palette = Theme.of(context).extension()!; + final historyItems = [ + _HistoryItemData( + question: l10n.historyQuestion1, + category: _HistoryCategory.career, + guaName: l10n.guaName1, + sign: _HistorySign.good, + ), + _HistoryItemData( + question: l10n.historyQuestion2, + category: _HistoryCategory.love, + guaName: l10n.guaName2, + sign: _HistorySign.normal, + ), + _HistoryItemData( + question: l10n.historyQuestion3, + category: _HistoryCategory.money, + guaName: l10n.guaName3, + sign: _HistorySign.best, + ), + ]; + + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.only( + top: AppSpacing.lg, + bottom: AppSpacing.lg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.helloUser( + widget.account.isEmpty + ? l10n.defaultUserName + : widget.account, + ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: colors.primary), + ), + Stack( + children: [ + IconButton( + onPressed: () { + setState(() { + _showNotificationDot = false; + }); + _showSnack(context, l10n.featurePending); + }, + icon: Icon( + Icons.notifications, + color: colors.primary, + size: 28, + ), + tooltip: l10n.notify, + ), + if (_showNotificationDot) + Positioned( + right: AppSpacing.sm, + top: AppSpacing.sm, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: palette.notificationDot, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: AppSpacing.xl), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.xl), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.lg), + gradient: LinearGradient( + colors: [colors.primary, palette.accentPurple], + ), + ), + child: Column( + children: [ + Icon( + Icons.auto_awesome, + color: colors.onPrimary, + size: 48, + ), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.startJourney, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + color: colors.onPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.journeySubtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onPrimary, + ), + ), + const SizedBox(height: AppSpacing.lg), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: colors.surface, + foregroundColor: colors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + onPressed: _onStartDivination, + child: Text(l10n.startNow), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.xl), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.historyTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + TextButton( + onPressed: () => _showSnack(context, l10n.featurePending), + child: Text(l10n.more), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + if (historyItems.isEmpty) + SizedBox( + width: double.infinity, + height: 200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.noRecords, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + Text(l10n.noRecordsSubtitle), + ], + ), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: historyItems.map((item) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: _HistoryCard(item: item), + ); + }).toList(), + ), + ], + ), + ), + ), + bottomNavigationBar: BottomNavBar( + currentTab: MainTab.home, + onTabChange: (_) {}, + onLogoTap: _onStartDivination, + ), + ); + } + + void _onStartDivination() { + final l10n = AppLocalizations.of(context)!; + _showSnack(context, l10n.featurePending); + } + + void _showSnack(BuildContext context, String message) { + Toast.show(context, message, type: ToastType.info); + } +} + +class _HistoryCard extends StatelessWidget { + const _HistoryCard({required this.item}); + + final _HistoryItemData item; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final palette = Theme.of(context).extension()!; + + final categoryLabel = switch (item.category) { + _HistoryCategory.career => l10n.categoryCareer, + _HistoryCategory.love => l10n.categoryLove, + _HistoryCategory.money => l10n.categoryMoney, + }; + + final categoryStyle = switch (item.category) { + _HistoryCategory.career => ( + palette.categoryCareerBg, + palette.categoryCareerText, + ), + _HistoryCategory.love => ( + palette.categoryLoveBg, + palette.categoryLoveText, + ), + _HistoryCategory.money => ( + palette.categoryMoneyBg, + palette.categoryMoneyText, + ), + }; + + final signLabel = switch (item.sign) { + _HistorySign.best => l10n.signBest, + _HistorySign.good => l10n.signGood, + _HistorySign.normal => l10n.signNormal, + }; + + final signStyle = switch (item.sign) { + _HistorySign.best => (palette.historyGoldBg, palette.historyGoldText), + _HistorySign.good => (colors.surfaceContainerHighest, colors.primary), + _HistorySign.normal => (palette.historyGrayBg, palette.historyGrayText), + }; + + return Card( + margin: EdgeInsets.zero, + color: colors.surface, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.question, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + _Tag( + label: categoryLabel, + background: categoryStyle.$1, + foreground: categoryStyle.$2, + ), + _Tag( + label: item.guaName, + background: palette.historyBlueBg, + foreground: palette.historyBlueText, + ), + _Tag( + label: signLabel, + background: signStyle.$1, + foreground: signStyle.$2, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _Tag extends StatelessWidget { + const _Tag({ + required this.label, + required this.background, + required this.foreground, + }); + + final String label; + final Color background; + final Color foreground; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: foreground), + ), + ); + } +} + +class _WelcomeDialog extends StatefulWidget { + const _WelcomeDialog({required this.onDone}); + + final Future Function() onDone; + + @override + State<_WelcomeDialog> createState() => _WelcomeDialogState(); +} + +class _WelcomeDialogState extends State<_WelcomeDialog> { + final ScrollController _scrollController = ScrollController(); + bool _hasScrolledToBottom = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_handleScroll); + WidgetsBinding.instance.addPostFrameCallback((_) { + _syncScrollState(); + }); + } + + @override + void dispose() { + _scrollController.removeListener(_handleScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _handleScroll() { + _syncScrollState(); + } + + void _syncScrollState() { + if (!_scrollController.hasClients) { + return; + } + final max = _scrollController.position.maxScrollExtent; + final current = _scrollController.offset; + final canReadAll = max <= AppSpacing.xs || current >= max - AppSpacing.md; + if (_hasScrolledToBottom == canReadAll) { + return; + } + setState(() { + _hasScrolledToBottom = canReadAll; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final palette = Theme.of(context).extension()!; + + return Dialog( + insetPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xl, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 620), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + children: [ + Text( + l10n.welcomeDialogTitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.lg), + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.welcomeParagraph1, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.welcomeParagraph2, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.welcomeParagraph3, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.warningTitle, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(color: palette.warning), + ), + const SizedBox(height: AppSpacing.xs), + Text( + l10n.warningBody, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: palette.warning, + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.md), + if (!_hasScrolledToBottom) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Text( + l10n.scrollHint, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _hasScrolledToBottom + ? () async { + await widget.onDone(); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + } + : null, + style: FilledButton.styleFrom( + backgroundColor: _hasScrolledToBottom + ? colors.primary + : colors.outline, + foregroundColor: colors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, + ), + child: Text( + _hasScrolledToBottom + ? l10n.understood + : l10n.readAllFirst, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +enum _HistoryCategory { career, love, money } + +enum _HistorySign { best, good, normal } + +class _HistoryItemData { + const _HistoryItemData({ + required this.question, + required this.category, + required this.guaName, + required this.sign, + }); + + final String question; + final _HistoryCategory category; + final String guaName; + final _HistorySign sign; +} diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb new file mode 100644 index 0000000..015fa40 --- /dev/null +++ b/apps/lib/l10n/app_en.arb @@ -0,0 +1,92 @@ +{ + "@@locale": "en", + "appTitle": "MeiYao Divination", + "welcomeLogin": "Welcome Back", + "loginSubtitle": "Sign in with your email", + "loginSubtitleEmail": "Sign in with your email", + "emailHint": "Enter email address", + "codeHint": "Enter verification code", + "sendCode": "Get Code", + "sending": "Sending...", + "retryAfter": "Retry in {seconds}s", + "@retryAfter": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "login": "Login", + "agreementPrefix": "I have read and agree to ", + "privacyPolicy": "Privacy Policy", + "termsOfService": "Terms of Service", + "disclaimer": "Disclaimer", + "icp": "Yue ICP 2025428416-1A", + "invalidPhone": "Please enter a valid phone number", + "invalidEmail": "Please enter a valid email address", + "invalidCode": "Please enter a 6-digit code", + "agreementRequired": "Please accept the agreements first", + "codeSent": "Code sent successfully", + "loginSuccess": "Login success", + "helloUser": "Hi, {name}", + "@helloUser": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "startJourney": "Start Your Divination Journey", + "journeySubtitle": "Explore possibilities with AI", + "startNow": "Start Now", + "historyTitle": "History", + "more": "More", + "noRecords": "No records yet", + "noRecordsSubtitle": "You have not saved any records", + "homeTab": "Home", + "profileTab": "Me", + "notify": "Notifications", + "featurePending": "This feature is not connected yet", + "logout": "Logout", + "defaultUserName": "User", + "historyQuestion1": "Is this year a good time to change jobs?", + "historyQuestion2": "Can my relationship progress soon?", + "historyQuestion3": "What pace should I keep for investments this quarter?", + "guaName1": "Wuwang", + "guaName2": "Ge", + "guaName3": "Guan", + "welcomeDialogTitle": "Welcome to MeiYao Divination", + "welcomeParagraph1": "Welcome to MeiYao Divination, an AI-assisted platform for interpreting traditional Six-Line divination and exploring Chinese classic wisdom.", + "welcomeParagraph2": "Six-Line divination comes from the profound philosophy of the I Ching. It reflects how intention and timing are mapped into symbolic patterns.", + "welcomeParagraph3": "MeiYao Divination helps you look beyond narrow thinking, see opportunities and risks from a broader trend perspective, and make clearer decisions.", + "warningTitle": "Important Notice", + "warningBody": "All interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.", + "scrollHint": "Scroll down to read all", + "understood": "I Understand", + "readAllFirst": "Please read all first", + "categoryCareer": "Career/Study", + "categoryLove": "Love/Marriage", + "categoryMoney": "Wealth/Investment", + "signBest": "Excellent", + "signGood": "Good", + "signNormal": "Moderate", + "language": "Language", + "english": "English", + "chinese": "Chinese", + "dialogConfirm": "OK", + "agreementSeparator": ", ", + "agreementAnd": " and ", + "privacyContent": "Placeholder content for privacy policy.", + "termsContent": "Placeholder content for terms of service.", + "disclaimerContent": "Placeholder content for disclaimer.", + "toastLabelInfo": "Info", + "toastLabelSuccess": "Success", + "toastLabelWarning": "Warning", + "toastLabelError": "Error", + "errorTooManyRequests": "Too many requests, please try again later", + "errorInvalidVerificationCode": "Invalid verification code", + "errorSessionExpired": "Session expired, please login again", + "errorServiceUnavailable": "Service unavailable, please try again later", + "errorServerGeneric": "Server error, please try again later", + "errorRequestGeneric": "Request failed, please try again" +} diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..37a126e --- /dev/null +++ b/apps/lib/l10n/app_localizations.dart @@ -0,0 +1,584 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('zh'), + ]; + + /// No description provided for @appTitle. + /// + /// In zh, this message translates to: + /// **'觅爻签问'** + String get appTitle; + + /// No description provided for @welcomeLogin. + /// + /// In zh, this message translates to: + /// **'欢迎登录'** + String get welcomeLogin; + + /// No description provided for @loginSubtitle. + /// + /// In zh, this message translates to: + /// **'请使用邮箱登录'** + String get loginSubtitle; + + /// No description provided for @loginSubtitleEmail. + /// + /// In zh, this message translates to: + /// **'请使用邮箱登录'** + String get loginSubtitleEmail; + + /// No description provided for @emailHint. + /// + /// In zh, this message translates to: + /// **'请输入邮箱地址'** + String get emailHint; + + /// No description provided for @codeHint. + /// + /// In zh, this message translates to: + /// **'请输入验证码'** + String get codeHint; + + /// No description provided for @sendCode. + /// + /// In zh, this message translates to: + /// **'获取验证码'** + String get sendCode; + + /// No description provided for @sending. + /// + /// In zh, this message translates to: + /// **'发送中...'** + String get sending; + + /// No description provided for @retryAfter. + /// + /// In zh, this message translates to: + /// **'{seconds}秒后重试'** + String retryAfter(int seconds); + + /// No description provided for @login. + /// + /// In zh, this message translates to: + /// **'登录'** + String get login; + + /// No description provided for @agreementPrefix. + /// + /// In zh, this message translates to: + /// **'我已阅读并同意'** + String get agreementPrefix; + + /// No description provided for @privacyPolicy. + /// + /// In zh, this message translates to: + /// **'隐私政策'** + String get privacyPolicy; + + /// No description provided for @termsOfService. + /// + /// In zh, this message translates to: + /// **'服务条款'** + String get termsOfService; + + /// No description provided for @disclaimer. + /// + /// In zh, this message translates to: + /// **'免责声明'** + String get disclaimer; + + /// No description provided for @icp. + /// + /// In zh, this message translates to: + /// **'粤ICP备2025428416号-1A'** + String get icp; + + /// No description provided for @invalidPhone. + /// + /// In zh, this message translates to: + /// **'请输入正确的手机号码'** + String get invalidPhone; + + /// No description provided for @invalidEmail. + /// + /// In zh, this message translates to: + /// **'请输入正确的邮箱地址'** + String get invalidEmail; + + /// No description provided for @invalidCode. + /// + /// In zh, this message translates to: + /// **'请输入6位验证码'** + String get invalidCode; + + /// No description provided for @agreementRequired. + /// + /// In zh, this message translates to: + /// **'请先勾选协议'** + String get agreementRequired; + + /// No description provided for @codeSent. + /// + /// In zh, this message translates to: + /// **'验证码已发送,请注意查收'** + String get codeSent; + + /// No description provided for @loginSuccess. + /// + /// In zh, this message translates to: + /// **'登录成功'** + String get loginSuccess; + + /// No description provided for @helloUser. + /// + /// In zh, this message translates to: + /// **'您好,{name}'** + String helloUser(String name); + + /// No description provided for @startJourney. + /// + /// In zh, this message translates to: + /// **'开始您的卦象之旅'** + String get startJourney; + + /// No description provided for @journeySubtitle. + /// + /// In zh, this message translates to: + /// **'借助AI智能,探索未来的可能'** + String get journeySubtitle; + + /// No description provided for @startNow. + /// + /// In zh, this message translates to: + /// **'立即起卦'** + String get startNow; + + /// No description provided for @historyTitle. + /// + /// In zh, this message translates to: + /// **'历史解卦'** + String get historyTitle; + + /// No description provided for @more. + /// + /// In zh, this message translates to: + /// **'更多'** + String get more; + + /// No description provided for @noRecords. + /// + /// In zh, this message translates to: + /// **'暂无记录'** + String get noRecords; + + /// No description provided for @noRecordsSubtitle. + /// + /// In zh, this message translates to: + /// **'您并没有保存任何卦象'** + String get noRecordsSubtitle; + + /// No description provided for @homeTab. + /// + /// In zh, this message translates to: + /// **'首页'** + String get homeTab; + + /// No description provided for @profileTab. + /// + /// In zh, this message translates to: + /// **'我的'** + String get profileTab; + + /// No description provided for @notify. + /// + /// In zh, this message translates to: + /// **'消息通知'** + String get notify; + + /// No description provided for @featurePending. + /// + /// In zh, this message translates to: + /// **'该功能暂未接入数据'** + String get featurePending; + + /// No description provided for @logout. + /// + /// In zh, this message translates to: + /// **'退出登录'** + String get logout; + + /// No description provided for @defaultUserName. + /// + /// In zh, this message translates to: + /// **'用户'** + String get defaultUserName; + + /// No description provided for @historyQuestion1. + /// + /// In zh, this message translates to: + /// **'今年转岗是否合适?'** + String get historyQuestion1; + + /// No description provided for @historyQuestion2. + /// + /// In zh, this message translates to: + /// **'最近感情是否能推进?'** + String get historyQuestion2; + + /// No description provided for @historyQuestion3. + /// + /// In zh, this message translates to: + /// **'本季度投资节奏如何?'** + String get historyQuestion3; + + /// No description provided for @guaName1. + /// + /// In zh, this message translates to: + /// **'天雷无妄'** + String get guaName1; + + /// No description provided for @guaName2. + /// + /// In zh, this message translates to: + /// **'泽火革'** + String get guaName2; + + /// No description provided for @guaName3. + /// + /// In zh, this message translates to: + /// **'风地观'** + String get guaName3; + + /// No description provided for @welcomeDialogTitle. + /// + /// In zh, this message translates to: + /// **'欢迎使用觅爻签问'** + String get welcomeDialogTitle; + + /// No description provided for @welcomeParagraph1. + /// + /// In zh, this message translates to: + /// **'你好,欢迎来到觅爻签问,这是一个借助于AI解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。'** + String get welcomeParagraph1; + + /// No description provided for @welcomeParagraph2. + /// + /// In zh, this message translates to: + /// **'六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。'** + String get welcomeParagraph2; + + /// No description provided for @welcomeParagraph3. + /// + /// In zh, this message translates to: + /// **'觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。'** + String get welcomeParagraph3; + + /// No description provided for @warningTitle. + /// + /// In zh, this message translates to: + /// **'特别提醒'** + String get warningTitle; + + /// No description provided for @warningBody. + /// + /// In zh, this message translates to: + /// **'卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。'** + String get warningBody; + + /// No description provided for @scrollHint. + /// + /// In zh, this message translates to: + /// **'请向下滚动阅读全部内容'** + String get scrollHint; + + /// No description provided for @understood. + /// + /// In zh, this message translates to: + /// **'我已了解'** + String get understood; + + /// No description provided for @readAllFirst. + /// + /// In zh, this message translates to: + /// **'请先阅读完整内容'** + String get readAllFirst; + + /// No description provided for @categoryCareer. + /// + /// In zh, this message translates to: + /// **'事业学业'** + String get categoryCareer; + + /// No description provided for @categoryLove. + /// + /// In zh, this message translates to: + /// **'情感婚姻'** + String get categoryLove; + + /// No description provided for @categoryMoney. + /// + /// In zh, this message translates to: + /// **'财富投资'** + String get categoryMoney; + + /// No description provided for @signBest. + /// + /// In zh, this message translates to: + /// **'上上签'** + String get signBest; + + /// No description provided for @signGood. + /// + /// In zh, this message translates to: + /// **'中上签'** + String get signGood; + + /// No description provided for @signNormal. + /// + /// In zh, this message translates to: + /// **'中下签'** + String get signNormal; + + /// No description provided for @language. + /// + /// In zh, this message translates to: + /// **'语言'** + String get language; + + /// No description provided for @english. + /// + /// In zh, this message translates to: + /// **'英文'** + String get english; + + /// No description provided for @chinese. + /// + /// In zh, this message translates to: + /// **'中文'** + String get chinese; + + /// No description provided for @dialogConfirm. + /// + /// In zh, this message translates to: + /// **'确定'** + String get dialogConfirm; + + /// No description provided for @agreementSeparator. + /// + /// In zh, this message translates to: + /// **'、'** + String get agreementSeparator; + + /// No description provided for @agreementAnd. + /// + /// In zh, this message translates to: + /// **'和'** + String get agreementAnd; + + /// No description provided for @privacyContent. + /// + /// In zh, this message translates to: + /// **'隐私政策内容展示占位。'** + String get privacyContent; + + /// No description provided for @termsContent. + /// + /// In zh, this message translates to: + /// **'服务条款内容展示占位。'** + String get termsContent; + + /// No description provided for @disclaimerContent. + /// + /// In zh, this message translates to: + /// **'免责声明内容展示占位。'** + String get disclaimerContent; + + /// No description provided for @toastLabelInfo. + /// + /// In zh, this message translates to: + /// **'提示'** + String get toastLabelInfo; + + /// No description provided for @toastLabelSuccess. + /// + /// In zh, this message translates to: + /// **'成功'** + String get toastLabelSuccess; + + /// No description provided for @toastLabelWarning. + /// + /// In zh, this message translates to: + /// **'警告'** + String get toastLabelWarning; + + /// No description provided for @toastLabelError. + /// + /// In zh, this message translates to: + /// **'错误'** + String get toastLabelError; + + /// No description provided for @errorTooManyRequests. + /// + /// In zh, this message translates to: + /// **'请求过于频繁,请稍后重试'** + String get errorTooManyRequests; + + /// No description provided for @errorInvalidVerificationCode. + /// + /// In zh, this message translates to: + /// **'验证码错误'** + String get errorInvalidVerificationCode; + + /// No description provided for @errorSessionExpired. + /// + /// In zh, this message translates to: + /// **'登录已过期,请重新登录'** + String get errorSessionExpired; + + /// No description provided for @errorServiceUnavailable. + /// + /// In zh, this message translates to: + /// **'服务暂时不可用,请稍后重试'** + String get errorServiceUnavailable; + + /// No description provided for @errorServerGeneric. + /// + /// In zh, this message translates to: + /// **'服务异常,请稍后重试'** + String get errorServerGeneric; + + /// No description provided for @errorRequestGeneric. + /// + /// In zh, this message translates to: + /// **'请求失败,请稍后重试'** + String get errorRequestGeneric; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..46cc5cc --- /dev/null +++ b/apps/lib/l10n/app_localizations_en.dart @@ -0,0 +1,246 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'MeiYao Divination'; + + @override + String get welcomeLogin => 'Welcome Back'; + + @override + String get loginSubtitle => 'Sign in with your email'; + + @override + String get loginSubtitleEmail => 'Sign in with your email'; + + @override + String get emailHint => 'Enter email address'; + + @override + String get codeHint => 'Enter verification code'; + + @override + String get sendCode => 'Get Code'; + + @override + String get sending => 'Sending...'; + + @override + String retryAfter(int seconds) { + return 'Retry in ${seconds}s'; + } + + @override + String get login => 'Login'; + + @override + String get agreementPrefix => 'I have read and agree to '; + + @override + String get privacyPolicy => 'Privacy Policy'; + + @override + String get termsOfService => 'Terms of Service'; + + @override + String get disclaimer => 'Disclaimer'; + + @override + String get icp => 'Yue ICP 2025428416-1A'; + + @override + String get invalidPhone => 'Please enter a valid phone number'; + + @override + String get invalidEmail => 'Please enter a valid email address'; + + @override + String get invalidCode => 'Please enter a 6-digit code'; + + @override + String get agreementRequired => 'Please accept the agreements first'; + + @override + String get codeSent => 'Code sent successfully'; + + @override + String get loginSuccess => 'Login success'; + + @override + String helloUser(String name) { + return 'Hi, $name'; + } + + @override + String get startJourney => 'Start Your Divination Journey'; + + @override + String get journeySubtitle => 'Explore possibilities with AI'; + + @override + String get startNow => 'Start Now'; + + @override + String get historyTitle => 'History'; + + @override + String get more => 'More'; + + @override + String get noRecords => 'No records yet'; + + @override + String get noRecordsSubtitle => 'You have not saved any records'; + + @override + String get homeTab => 'Home'; + + @override + String get profileTab => 'Me'; + + @override + String get notify => 'Notifications'; + + @override + String get featurePending => 'This feature is not connected yet'; + + @override + String get logout => 'Logout'; + + @override + String get defaultUserName => 'User'; + + @override + String get historyQuestion1 => 'Is this year a good time to change jobs?'; + + @override + String get historyQuestion2 => 'Can my relationship progress soon?'; + + @override + String get historyQuestion3 => + 'What pace should I keep for investments this quarter?'; + + @override + String get guaName1 => 'Wuwang'; + + @override + String get guaName2 => 'Ge'; + + @override + String get guaName3 => 'Guan'; + + @override + String get welcomeDialogTitle => 'Welcome to MeiYao Divination'; + + @override + String get welcomeParagraph1 => + 'Welcome to MeiYao Divination, an AI-assisted platform for interpreting traditional Six-Line divination and exploring Chinese classic wisdom.'; + + @override + String get welcomeParagraph2 => + 'Six-Line divination comes from the profound philosophy of the I Ching. It reflects how intention and timing are mapped into symbolic patterns.'; + + @override + String get welcomeParagraph3 => + 'MeiYao Divination helps you look beyond narrow thinking, see opportunities and risks from a broader trend perspective, and make clearer decisions.'; + + @override + String get warningTitle => 'Important Notice'; + + @override + String get warningBody => + 'All interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.'; + + @override + String get scrollHint => 'Scroll down to read all'; + + @override + String get understood => 'I Understand'; + + @override + String get readAllFirst => 'Please read all first'; + + @override + String get categoryCareer => 'Career/Study'; + + @override + String get categoryLove => 'Love/Marriage'; + + @override + String get categoryMoney => 'Wealth/Investment'; + + @override + String get signBest => 'Excellent'; + + @override + String get signGood => 'Good'; + + @override + String get signNormal => 'Moderate'; + + @override + String get language => 'Language'; + + @override + String get english => 'English'; + + @override + String get chinese => 'Chinese'; + + @override + String get dialogConfirm => 'OK'; + + @override + String get agreementSeparator => ', '; + + @override + String get agreementAnd => ' and '; + + @override + String get privacyContent => 'Placeholder content for privacy policy.'; + + @override + String get termsContent => 'Placeholder content for terms of service.'; + + @override + String get disclaimerContent => 'Placeholder content for disclaimer.'; + + @override + String get toastLabelInfo => 'Info'; + + @override + String get toastLabelSuccess => 'Success'; + + @override + String get toastLabelWarning => 'Warning'; + + @override + String get toastLabelError => 'Error'; + + @override + String get errorTooManyRequests => + 'Too many requests, please try again later'; + + @override + String get errorInvalidVerificationCode => 'Invalid verification code'; + + @override + String get errorSessionExpired => 'Session expired, please login again'; + + @override + String get errorServiceUnavailable => + 'Service unavailable, please try again later'; + + @override + String get errorServerGeneric => 'Server error, please try again later'; + + @override + String get errorRequestGeneric => 'Request failed, please try again'; +} diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart new file mode 100644 index 0000000..5645b19 --- /dev/null +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,243 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get appTitle => '觅爻签问'; + + @override + String get welcomeLogin => '欢迎登录'; + + @override + String get loginSubtitle => '请使用邮箱登录'; + + @override + String get loginSubtitleEmail => '请使用邮箱登录'; + + @override + String get emailHint => '请输入邮箱地址'; + + @override + String get codeHint => '请输入验证码'; + + @override + String get sendCode => '获取验证码'; + + @override + String get sending => '发送中...'; + + @override + String retryAfter(int seconds) { + return '$seconds秒后重试'; + } + + @override + String get login => '登录'; + + @override + String get agreementPrefix => '我已阅读并同意'; + + @override + String get privacyPolicy => '隐私政策'; + + @override + String get termsOfService => '服务条款'; + + @override + String get disclaimer => '免责声明'; + + @override + String get icp => '粤ICP备2025428416号-1A'; + + @override + String get invalidPhone => '请输入正确的手机号码'; + + @override + String get invalidEmail => '请输入正确的邮箱地址'; + + @override + String get invalidCode => '请输入6位验证码'; + + @override + String get agreementRequired => '请先勾选协议'; + + @override + String get codeSent => '验证码已发送,请注意查收'; + + @override + String get loginSuccess => '登录成功'; + + @override + String helloUser(String name) { + return '您好,$name'; + } + + @override + String get startJourney => '开始您的卦象之旅'; + + @override + String get journeySubtitle => '借助AI智能,探索未来的可能'; + + @override + String get startNow => '立即起卦'; + + @override + String get historyTitle => '历史解卦'; + + @override + String get more => '更多'; + + @override + String get noRecords => '暂无记录'; + + @override + String get noRecordsSubtitle => '您并没有保存任何卦象'; + + @override + String get homeTab => '首页'; + + @override + String get profileTab => '我的'; + + @override + String get notify => '消息通知'; + + @override + String get featurePending => '该功能暂未接入数据'; + + @override + String get logout => '退出登录'; + + @override + String get defaultUserName => '用户'; + + @override + String get historyQuestion1 => '今年转岗是否合适?'; + + @override + String get historyQuestion2 => '最近感情是否能推进?'; + + @override + String get historyQuestion3 => '本季度投资节奏如何?'; + + @override + String get guaName1 => '天雷无妄'; + + @override + String get guaName2 => '泽火革'; + + @override + String get guaName3 => '风地观'; + + @override + String get welcomeDialogTitle => '欢迎使用觅爻签问'; + + @override + String get welcomeParagraph1 => + '你好,欢迎来到觅爻签问,这是一个借助于AI解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。'; + + @override + String get welcomeParagraph2 => + '六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。'; + + @override + String get welcomeParagraph3 => + '觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。'; + + @override + String get warningTitle => '特别提醒'; + + @override + String get warningBody => + '卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。'; + + @override + String get scrollHint => '请向下滚动阅读全部内容'; + + @override + String get understood => '我已了解'; + + @override + String get readAllFirst => '请先阅读完整内容'; + + @override + String get categoryCareer => '事业学业'; + + @override + String get categoryLove => '情感婚姻'; + + @override + String get categoryMoney => '财富投资'; + + @override + String get signBest => '上上签'; + + @override + String get signGood => '中上签'; + + @override + String get signNormal => '中下签'; + + @override + String get language => '语言'; + + @override + String get english => '英文'; + + @override + String get chinese => '中文'; + + @override + String get dialogConfirm => '确定'; + + @override + String get agreementSeparator => '、'; + + @override + String get agreementAnd => '和'; + + @override + String get privacyContent => '隐私政策内容展示占位。'; + + @override + String get termsContent => '服务条款内容展示占位。'; + + @override + String get disclaimerContent => '免责声明内容展示占位。'; + + @override + String get toastLabelInfo => '提示'; + + @override + String get toastLabelSuccess => '成功'; + + @override + String get toastLabelWarning => '警告'; + + @override + String get toastLabelError => '错误'; + + @override + String get errorTooManyRequests => '请求过于频繁,请稍后重试'; + + @override + String get errorInvalidVerificationCode => '验证码错误'; + + @override + String get errorSessionExpired => '登录已过期,请重新登录'; + + @override + String get errorServiceUnavailable => '服务暂时不可用,请稍后重试'; + + @override + String get errorServerGeneric => '服务异常,请稍后重试'; + + @override + String get errorRequestGeneric => '请求失败,请稍后重试'; +} diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index 96c94f0..7a8c9d4 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -2,8 +2,9 @@ "@@locale": "zh", "appTitle": "觅爻签问", "welcomeLogin": "欢迎登录", - "loginSubtitle": "请使用手机号登录", - "phoneHint": "请输入手机号码", + "loginSubtitle": "请使用邮箱登录", + "loginSubtitleEmail": "请使用邮箱登录", + "emailHint": "请输入邮箱地址", "codeHint": "请输入验证码", "sendCode": "获取验证码", "sending": "发送中...", @@ -22,10 +23,11 @@ "disclaimer": "免责声明", "icp": "粤ICP备2025428416号-1A", "invalidPhone": "请输入正确的手机号码", + "invalidEmail": "请输入正确的邮箱地址", "invalidCode": "请输入6位验证码", "agreementRequired": "请先勾选协议", "codeSent": "验证码已发送,请注意查收", - "mockLoginSuccess": "模拟登录成功", + "loginSuccess": "登录成功", "helloUser": "您好,{name}", "@helloUser": { "placeholders": { @@ -45,6 +47,14 @@ "profileTab": "我的", "notify": "消息通知", "featurePending": "该功能暂未接入数据", + "logout": "退出登录", + "defaultUserName": "用户", + "historyQuestion1": "今年转岗是否合适?", + "historyQuestion2": "最近感情是否能推进?", + "historyQuestion3": "本季度投资节奏如何?", + "guaName1": "天雷无妄", + "guaName2": "泽火革", + "guaName3": "风地观", "welcomeDialogTitle": "欢迎使用觅爻签问", "welcomeParagraph1": "你好,欢迎来到觅爻签问,这是一个借助于AI解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。", "welcomeParagraph2": "六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。", @@ -63,7 +73,20 @@ "language": "语言", "english": "英文", "chinese": "中文", + "dialogConfirm": "确定", + "agreementSeparator": "、", + "agreementAnd": "和", "privacyContent": "隐私政策内容展示占位。", "termsContent": "服务条款内容展示占位。", - "disclaimerContent": "免责声明内容展示占位。" + "disclaimerContent": "免责声明内容展示占位。", + "toastLabelInfo": "提示", + "toastLabelSuccess": "成功", + "toastLabelWarning": "警告", + "toastLabelError": "错误", + "errorTooManyRequests": "请求过于频繁,请稍后重试", + "errorInvalidVerificationCode": "验证码错误", + "errorSessionExpired": "登录已过期,请重新登录", + "errorServiceUnavailable": "服务暂时不可用,请稍后重试", + "errorServerGeneric": "服务异常,请稍后重试", + "errorRequestGeneric": "请求失败,请稍后重试" } diff --git a/apps/lib/main.dart b/apps/lib/main.dart index 22d17cc..88aab37 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -1,8 +1,25 @@ import 'package:flutter/widgets.dart'; import 'app/app.dart'; +import 'app/di/injection.dart'; +import 'core/config/env.dart'; +import 'core/logging/error_handler.dart'; +import 'core/logging/log_service.dart'; +import 'core/logging/logger.dart'; -void main() { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); + final logService = await LogService.create(); + Logger.setLogService(logService); + AppErrorHandler().register(); + + await Env.init(); + await configureDependencies(); + + getLogger('app').info( + message: 'App starting', + extra: {'backend_url': appDependencies.backendUrl}, + ); + runApp(const EryaoApp()); } diff --git a/apps/lib/shared/theme/app_color_palette.dart b/apps/lib/shared/theme/app_color_palette.dart new file mode 100644 index 0000000..0d2721c --- /dev/null +++ b/apps/lib/shared/theme/app_color_palette.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; + +@immutable +class AppColorPalette extends ThemeExtension { + const AppColorPalette({ + required this.accentPurple, + required this.historyGoldBg, + required this.historyGoldText, + required this.historyBlueBg, + required this.historyBlueText, + required this.historyGrayBg, + required this.historyGrayText, + required this.categoryCareerBg, + required this.categoryCareerText, + required this.categoryLoveBg, + required this.categoryLoveText, + required this.categoryMoneyBg, + required this.categoryMoneyText, + required this.notificationDot, + required this.warning, + required this.warningContainer, + required this.onWarningContainer, + }); + + final Color accentPurple; + final Color historyGoldBg; + final Color historyGoldText; + final Color historyBlueBg; + final Color historyBlueText; + final Color historyGrayBg; + final Color historyGrayText; + final Color categoryCareerBg; + final Color categoryCareerText; + final Color categoryLoveBg; + final Color categoryLoveText; + final Color categoryMoneyBg; + final Color categoryMoneyText; + final Color notificationDot; + final Color warning; + final Color warningContainer; + final Color onWarningContainer; + + @override + ThemeExtension copyWith({ + Color? accentPurple, + Color? historyGoldBg, + Color? historyGoldText, + Color? historyBlueBg, + Color? historyBlueText, + Color? historyGrayBg, + Color? historyGrayText, + Color? categoryCareerBg, + Color? categoryCareerText, + Color? categoryLoveBg, + Color? categoryLoveText, + Color? categoryMoneyBg, + Color? categoryMoneyText, + Color? notificationDot, + Color? warning, + Color? warningContainer, + Color? onWarningContainer, + }) { + return AppColorPalette( + accentPurple: accentPurple ?? this.accentPurple, + historyGoldBg: historyGoldBg ?? this.historyGoldBg, + historyGoldText: historyGoldText ?? this.historyGoldText, + historyBlueBg: historyBlueBg ?? this.historyBlueBg, + historyBlueText: historyBlueText ?? this.historyBlueText, + historyGrayBg: historyGrayBg ?? this.historyGrayBg, + historyGrayText: historyGrayText ?? this.historyGrayText, + categoryCareerBg: categoryCareerBg ?? this.categoryCareerBg, + categoryCareerText: categoryCareerText ?? this.categoryCareerText, + categoryLoveBg: categoryLoveBg ?? this.categoryLoveBg, + categoryLoveText: categoryLoveText ?? this.categoryLoveText, + categoryMoneyBg: categoryMoneyBg ?? this.categoryMoneyBg, + categoryMoneyText: categoryMoneyText ?? this.categoryMoneyText, + notificationDot: notificationDot ?? this.notificationDot, + warning: warning ?? this.warning, + warningContainer: warningContainer ?? this.warningContainer, + onWarningContainer: onWarningContainer ?? this.onWarningContainer, + ); + } + + @override + ThemeExtension lerp( + covariant ThemeExtension? other, + double t, + ) { + if (other is! AppColorPalette) { + return this; + } + return AppColorPalette( + accentPurple: Color.lerp(accentPurple, other.accentPurple, t)!, + historyGoldBg: Color.lerp(historyGoldBg, other.historyGoldBg, t)!, + historyGoldText: Color.lerp(historyGoldText, other.historyGoldText, t)!, + historyBlueBg: Color.lerp(historyBlueBg, other.historyBlueBg, t)!, + historyBlueText: Color.lerp(historyBlueText, other.historyBlueText, t)!, + historyGrayBg: Color.lerp(historyGrayBg, other.historyGrayBg, t)!, + historyGrayText: Color.lerp(historyGrayText, other.historyGrayText, t)!, + categoryCareerBg: Color.lerp( + categoryCareerBg, + other.categoryCareerBg, + t, + )!, + categoryCareerText: Color.lerp( + categoryCareerText, + other.categoryCareerText, + t, + )!, + categoryLoveBg: Color.lerp(categoryLoveBg, other.categoryLoveBg, t)!, + categoryLoveText: Color.lerp( + categoryLoveText, + other.categoryLoveText, + t, + )!, + categoryMoneyBg: Color.lerp(categoryMoneyBg, other.categoryMoneyBg, t)!, + categoryMoneyText: Color.lerp( + categoryMoneyText, + other.categoryMoneyText, + t, + )!, + notificationDot: Color.lerp(notificationDot, other.notificationDot, t)!, + warning: Color.lerp(warning, other.warning, t)!, + warningContainer: Color.lerp( + warningContainer, + other.warningContainer, + t, + )!, + onWarningContainer: Color.lerp( + onWarningContainer, + other.onWarningContainer, + t, + )!, + ); + } +} diff --git a/apps/lib/shared/theme/design_tokens.dart b/apps/lib/shared/theme/design_tokens.dart new file mode 100644 index 0000000..afffc4b --- /dev/null +++ b/apps/lib/shared/theme/design_tokens.dart @@ -0,0 +1,17 @@ +class AppSpacing { + static const double xs = 4; + static const double sm = 8; + static const double md = 12; + static const double lg = 16; + static const double xl = 24; + static const double xxl = 32; + static const double xxxl = 60; +} + +class AppRadius { + static const double sm = 8; + static const double md = 12; + static const double lg = 16; + static const double xl = 20; + static const double full = 999; +} diff --git a/apps/lib/shared/widgets/app_banner.dart b/apps/lib/shared/widgets/app_banner.dart new file mode 100644 index 0000000..868d5fb --- /dev/null +++ b/apps/lib/shared/widgets/app_banner.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import '../theme/design_tokens.dart'; +import 'toast/toast_type.dart'; +import 'toast/toast_type_config.dart' show ToastTypeConfig; + +class AppBanner extends StatelessWidget { + final String message; + final ToastType type; + final bool visible; + final String? title; + + const AppBanner({ + super.key, + required this.message, + this.type = ToastType.warning, + this.visible = true, + this.title, + }); + + @override + Widget build(BuildContext context) { + if (!visible) return const SizedBox.shrink(); + + final config = ToastTypeConfig.fromType(context, type); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: config.surfaceColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: config.borderColor), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: config.iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Icon(config.icon, size: 16, color: config.iconColor), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title ?? config.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: config.textColor, + ), + ), + const SizedBox(height: 2), + Text( + message, + style: TextStyle( + fontSize: 13, + height: 1.35, + color: config.textColor, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/lib/shared/widgets/app_loading_indicator.dart b/apps/lib/shared/widgets/app_loading_indicator.dart new file mode 100644 index 0000000..6f60182 --- /dev/null +++ b/apps/lib/shared/widgets/app_loading_indicator.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import '../theme/design_tokens.dart'; + +enum AppLoadingVariant { surface, inline, button } + +class AppLoadingIndicator extends StatelessWidget { + const AppLoadingIndicator({ + super.key, + this.variant = AppLoadingVariant.surface, + this.size, + this.strokeWidth, + this.color, + this.trackColor, + this.withContainer, + }); + + final AppLoadingVariant variant; + final double? size; + final double? strokeWidth; + final Color? color; + final Color? trackColor; + final bool? withContainer; + + double get _resolvedSize { + return size ?? + switch (variant) { + AppLoadingVariant.surface => 22, + AppLoadingVariant.inline => 16, + AppLoadingVariant.button => 18, + }; + } + + double get _resolvedStrokeWidth { + return strokeWidth ?? + switch (variant) { + AppLoadingVariant.surface => 2.2, + AppLoadingVariant.inline => 2, + AppLoadingVariant.button => 2.2, + }; + } + + Widget _buildSpinner(Color color, Color trackColor) { + return SizedBox( + width: _resolvedSize, + height: _resolvedSize, + child: CircularProgressIndicator( + strokeWidth: _resolvedStrokeWidth, + color: color, + backgroundColor: trackColor, + ), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + final resolvedColor = + color ?? + switch (variant) { + AppLoadingVariant.surface => colorScheme.primary, + AppLoadingVariant.inline => colorScheme.onSurfaceVariant, + AppLoadingVariant.button => colorScheme.onPrimary, + }; + + final resolvedTrackColor = + trackColor ?? + switch (variant) { + AppLoadingVariant.surface => colorScheme.primaryContainer, + AppLoadingVariant.inline => colorScheme.outlineVariant, + AppLoadingVariant.button => colorScheme.secondary, + }; + + if (withContainer == false || + (withContainer == null && + switch (variant) { + AppLoadingVariant.surface => true, + AppLoadingVariant.inline => false, + AppLoadingVariant.button => false, + })) { + return _buildSpinner(resolvedColor, resolvedTrackColor); + } + + return Container( + width: _resolvedSize + AppSpacing.md, + height: _resolvedSize + AppSpacing.md, + padding: const EdgeInsets.all(AppSpacing.xs), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: colorScheme.outlineVariant), + boxShadow: [ + BoxShadow( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + blurRadius: AppRadius.md, + offset: const Offset(0, AppSpacing.xs), + ), + ], + ), + child: _buildSpinner(resolvedColor, resolvedTrackColor), + ); + } +} diff --git a/apps/lib/shared/widgets/bottom_nav_bar.dart b/apps/lib/shared/widgets/bottom_nav_bar.dart new file mode 100644 index 0000000..e8c43b6 --- /dev/null +++ b/apps/lib/shared/widgets/bottom_nav_bar.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; + +import '../theme/design_tokens.dart'; + +enum MainTab { home, profile } + +class BottomNavBar extends StatelessWidget { + const BottomNavBar({ + super.key, + required this.currentTab, + required this.onTabChange, + required this.onLogoTap, + }); + + final MainTab currentTab; + final ValueChanged onTabChange; + final VoidCallback onLogoTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + color: colors.surface, + child: SafeArea( + top: false, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _NavItem( + icon: Icons.home, + label: l10n.homeTab, + selected: currentTab == MainTab.home, + onTap: () => onTabChange(MainTab.home), + ), + GestureDetector( + onTap: onLogoTap, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Image.asset( + 'assets/images/logo.png', + width: 56, + height: 56, + fit: BoxFit.cover, + ), + ), + ), + _NavItem( + icon: Icons.person, + label: l10n.profileTab, + selected: currentTab == MainTab.profile, + onTap: () => onTabChange(MainTab.profile), + ), + ], + ), + ), + ); + } +} + +class _NavItem extends StatelessWidget { + const _NavItem({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final iconColor = selected ? colors.primary : colors.outline; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: iconColor), + const SizedBox(height: AppSpacing.xs), + Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: iconColor), + ), + ], + ), + ), + ); + } +} diff --git a/apps/lib/shared/widgets/toast/toast.dart b/apps/lib/shared/widgets/toast/toast.dart new file mode 100644 index 0000000..4394ac1 --- /dev/null +++ b/apps/lib/shared/widgets/toast/toast.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; + +import '../../theme/design_tokens.dart'; +import 'toast_type.dart'; +import 'toast_type_config.dart'; + +class Toast { + static void show( + BuildContext context, + String message, { + ToastType type = ToastType.info, + Duration duration = const Duration(seconds: 2), + }) { + final overlay = Overlay.of(context); + late OverlayEntry entry; + + entry = OverlayEntry( + builder: (context) => _ToastWidget( + message: message, + type: type, + duration: duration, + onDismiss: () => entry.remove(), + ), + ); + + overlay.insert(entry); + } +} + +class _ToastWidget extends StatefulWidget { + final String message; + final ToastType type; + final Duration duration; + final VoidCallback onDismiss; + + const _ToastWidget({ + required this.message, + required this.type, + required this.duration, + required this.onDismiss, + }); + + @override + State<_ToastWidget> createState() => _ToastWidgetState(); +} + +class _ToastWidgetState extends State<_ToastWidget> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _slideAnimation; + late Animation _fadeAnimation; + bool _dismissed = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 280), + vsync: this, + ); + + _slideAnimation = Tween( + begin: const Offset(0, -0.18), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + + _fadeAnimation = Tween( + begin: 0, + end: 1, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + _controller.forward(); + + Future.delayed(widget.duration, _dismiss); + } + + void _dismiss() { + if (!mounted || _dismissed) return; + _dismissed = true; + _controller.reverse().then((_) { + if (mounted) { + widget.onDismiss(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final config = ToastTypeConfig.fromType(context, widget.type); + final colorScheme = Theme.of(context).colorScheme; + + return Positioned( + top: MediaQuery.of(context).padding.top + 12, + left: 16, + right: 16, + child: SlideTransition( + position: _slideAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: Material( + color: Colors.transparent, + child: SafeArea( + bottom: false, + child: GestureDetector( + onTap: _dismiss, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: config.surfaceColor, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: config.borderColor), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: config.iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Icon( + config.icon, + size: 18, + color: config.iconColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + config.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: config.textColor, + ), + ), + const SizedBox(height: 2), + Text( + widget.message, + style: TextStyle( + fontSize: 14, + height: 1.35, + color: config.textColor, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.close_rounded, + size: 18, + color: config.textColor.withValues(alpha: 0.72), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/lib/shared/widgets/toast/toast_type.dart b/apps/lib/shared/widgets/toast/toast_type.dart new file mode 100644 index 0000000..9c4add8 --- /dev/null +++ b/apps/lib/shared/widgets/toast/toast_type.dart @@ -0,0 +1 @@ +enum ToastType { info, success, warning, error } diff --git a/apps/lib/shared/widgets/toast/toast_type_config.dart b/apps/lib/shared/widgets/toast/toast_type_config.dart new file mode 100644 index 0000000..b92601a --- /dev/null +++ b/apps/lib/shared/widgets/toast/toast_type_config.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../../theme/app_color_palette.dart'; +import 'toast_type.dart'; + +class ToastTypeConfig { + final Color surfaceColor; + final Color borderColor; + final Color iconColor; + final Color textColor; + final String label; + final IconData icon; + + const ToastTypeConfig({ + required this.surfaceColor, + required this.borderColor, + required this.iconColor, + required this.textColor, + required this.label, + required this.icon, + }); + + static ToastTypeConfig fromType(BuildContext context, ToastType type) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + final palette = Theme.of(context).extension()!; + + return switch (type) { + ToastType.success => ToastTypeConfig( + surfaceColor: colorScheme.primaryContainer, + borderColor: colorScheme.primary, + iconColor: colorScheme.primary, + textColor: colorScheme.onPrimaryContainer, + label: l10n.toastLabelSuccess, + icon: Icons.check_circle_outline, + ), + ToastType.warning => ToastTypeConfig( + surfaceColor: palette.warningContainer, + borderColor: palette.warning, + iconColor: palette.warning, + textColor: palette.onWarningContainer, + label: l10n.toastLabelWarning, + icon: Icons.warning_amber_rounded, + ), + ToastType.error => ToastTypeConfig( + surfaceColor: colorScheme.errorContainer, + borderColor: colorScheme.error, + iconColor: colorScheme.error, + textColor: colorScheme.onErrorContainer, + label: l10n.toastLabelError, + icon: Icons.error_outline, + ), + ToastType.info => ToastTypeConfig( + surfaceColor: colorScheme.primaryContainer, + borderColor: colorScheme.primary, + iconColor: colorScheme.primary, + textColor: colorScheme.onPrimaryContainer, + label: l10n.toastLabelInfo, + icon: Icons.info_outline, + ), + }; + } +} diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 45d4cfb..2830353 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -34,6 +34,9 @@ dependencies: sdk: flutter intl: ^0.20.2 shared_preferences: ^2.5.3 + flutter_secure_storage: ^9.2.4 + dio: ^5.9.0 + path_provider: ^2.1.5 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -49,6 +52,14 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.14.4 + +flutter_launcher_icons: + android: true + ios: true + image_path: assets/images/logo.png + adaptive_icon_background: "#FFFFFF" + adaptive_icon_foreground: assets/images/logo.png # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/apps/rules/visual_design_language.md b/apps/rules/visual_design_language.md new file mode 100644 index 0000000..175a4c5 --- /dev/null +++ b/apps/rules/visual_design_language.md @@ -0,0 +1,167 @@ +# Visual Design Language for Eryao Flutter App + +This document defines the **visual design language** for `觅爻签问` under `apps/**`. +It is derived from: + +- the existing product visuals in `old/app/**` +- the structure and quality bar of `social-app/apps/rules/visual_design_language.md` + +It is the source of truth for visual consistency, surface hierarchy, and interaction tone. + +--- + +## 0) Scope and Role (MUST) + +- Applies to all Flutter UI work in `apps/**`. +- Defines visual intent, not business logic. +- If implementation constraints conflict with visual intent, keep the stricter implementation rule and preserve visual intent as much as possible. + +--- + +## 1) Product Design Goal (MUST) + +`觅爻签问` is a divination assistant app with Chinese cultural context. + +The UI must feel: + +- calm +- trustworthy +- warm-tech +- clear +- ceremonial but not mystical noise +- modern mobile-native + +Avoid: + +- enterprise admin panel aesthetics +- hyper-playful toy style +- over-dark cyber style +- ornamental visual clutter + +--- + +## 2) Core Style Direction (MUST) + +Primary style blend: + +- **purple-centered brand palette** from old app +- **soft gray background field** +- **white card surfaces with rounded corners** +- **gradient hero card for primary action** +- **compact tag system for category/sign states** + +Visual tone: + +- soft, layered, readable +- lightweight but premium +- expressive in key actions, restrained elsewhere + +--- + +## 3) Brand Palette (MUST) + +Canonical colors extracted from old app: + +- Primary Purple: `#673AB7` +- Accent Purple: `#9C27B0` +- Light Purple Surface: `#F0E6FF` +- Background Gray: `#F8F8F8` +- Text Dark: `#333333` +- Text Medium: `#666666` +- Text Light: `#999999` + +Tag/status support colors: + +- Gold result: bg `#FFF8E1`, text `#FFB300` +- Blue tag: bg `#E6F7FF`, text `#1890FF` +- Gray result: bg `#F5F5F5`, text `#9E9E9E` +- Warning: `#F57C00` + +All UI usage should go through `ColorScheme` and `AppColorPalette` extension. + +--- + +## 4) Surface Hierarchy (MUST) + +Every screen should read in this order: + +1. soft background field (`BackgroundGray` semantic slot) +2. core card modules (white rounded surfaces) +3. prominent primary-action hero card (purple gradient) +4. compact metadata chips (category/gua/sign) +5. subtle transient feedback (toast/snackbar) + +Do not rely on color alone; use spacing, corner radius, and elevation to express hierarchy. + +--- + +## 5) Shape and Spacing Language (MUST) + +- Rounded geometry is default. +- Primary card radius: `16dp`. +- Secondary card radius: `12dp`. +- Input/button radius: `8dp`. +- Spacing rhythm follows `4/8/12/16/24/32`. +- Preserve generous vertical breathing room on login and home. + +--- + +## 6) Typography and Tone (MUST) + +- Headline: strong, concise. +- Body: readable and neutral. +- Caption/meta: low emphasis but still legible. +- Chinese copy should remain culturally natural and non-gimmicky. +- English copy should be concise, product-appropriate, and semantically aligned. + +--- + +## 7) Page-Level Guidance (MUST) + +### Login + +- Top-left welcome title + subtitle. +- Phone input and code input row with fixed-width code button. +- Primary full-width login button. +- Agreement checkbox with inline clickable legal links. +- Bottom filing/registration text centered. + +### Home + +- Top greeting on the left, notification icon on the right with red-dot state. +- Gradient hero card with icon, title, subtitle, and main CTA. +- History section title + “more” entry. +- Vertical list of rounded history cards with compact status tags. +- Bottom nav: Home, center logo, Profile. +- First-entry modal: long welcome text requiring scroll-to-bottom confirmation. + +--- + +## 8) Interaction and Motion (SHOULD) + +- Keep transitions subtle and quick. +- Avoid heavy animation chains. +- Primary actions provide immediate feedback. +- Disabled states must be visually clear. + +--- + +## 9) Accessibility and Internationalization (MUST) + +- Support at least `zh` and `en`. +- Keep text scalable without overlap in critical controls. +- Ensure interactive targets are easy to tap. +- Contrast should remain readable on all key text/surface combinations. + +--- + +## 10) Android Continuity Requirements (MUST) + +To avoid losing local tokens/settings during upgrades: + +- Keep stable `applicationId` across releases. +- Keep release signing keystore stable per channel. +- Persist token/session in local key-value storage. +- Keep backup/data-extraction rules explicit for shared preferences. + +These are release continuity requirements and must be checked before each Android release. diff --git a/apps/test/core/network/api_problem_mapper_test.dart b/apps/test/core/network/api_problem_mapper_test.dart new file mode 100644 index 0000000..1053f71 --- /dev/null +++ b/apps/test/core/network/api_problem_mapper_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meeyao_qianwen/core/network/api_problem.dart'; +import 'package:meeyao_qianwen/core/network/api_problem_mapper.dart'; +import 'package:meeyao_qianwen/l10n/app_localizations.dart'; + +void main() { + testWidgets('map by code uses localized message', (tester) async { + late AppLocalizations l10n; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + l10n = AppLocalizations.of(context)!; + return const SizedBox.shrink(); + }, + ), + ), + ); + + final message = mapApiProblemToMessage( + ApiProblem( + status: 401, + title: 'Unauthorized', + detail: 'Invalid verification code', + code: 'AUTH_VERIFICATION_CODE_INVALID', + ), + l10n, + ); + + expect(message, l10n.errorInvalidVerificationCode); + }); +} diff --git a/apps/test/features/auth/auth_bloc_test.dart b/apps/test/features/auth/auth_bloc_test.dart new file mode 100644 index 0000000..4e4d1f9 --- /dev/null +++ b/apps/test/features/auth/auth_bloc_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meeyao_qianwen/features/auth/data/models/auth_user.dart'; +import 'package:meeyao_qianwen/features/auth/data/repositories/auth_repository.dart'; +import 'package:meeyao_qianwen/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:meeyao_qianwen/features/auth/presentation/bloc/auth_state.dart'; + +class _FakeAuthRepository implements AuthRepository { + _FakeAuthRepository({this.recoveredUser, this.throwOnLogout = false}); + + AuthUser? recoveredUser; + bool clearCalled = false; + bool logoutCalled = false; + bool throwOnLogout; + + @override + Future clearLocalSession() async { + clearCalled = true; + } + + @override + Future loginWithEmailOtp({ + required String email, + required String otp, + }) async { + return AuthUser(id: 'u1', email: email); + } + + @override + Future logout() async { + logoutCalled = true; + if (throwOnLogout) { + throw Exception('logout failed'); + } + } + + @override + Future recoverSession() async { + return recoveredUser; + } + + @override + Future sendOtp(String email) async {} +} + +void main() { + test('start should become authenticated when recover success', () async { + final repo = _FakeAuthRepository( + recoveredUser: const AuthUser(id: 'u1', email: 'a@b.com'), + ); + final bloc = AuthBloc(repository: repo); + + await bloc.start(); + + expect(bloc.state.status, AuthStatus.authenticated); + expect(bloc.state.user?.email, 'a@b.com'); + }); + + test('handleUnauthorized401 should clear local session and unauth', () async { + final repo = _FakeAuthRepository( + recoveredUser: const AuthUser(id: 'u1', email: 'a@b.com'), + ); + final bloc = AuthBloc(repository: repo); + + await bloc.start(); + await bloc.handleUnauthorized401(); + + expect(repo.clearCalled, isTrue); + expect(bloc.state.status, AuthStatus.unauthenticated); + }); + + test( + 'logout should set unauthenticated even when repository throws', + () async { + final repo = _FakeAuthRepository( + recoveredUser: const AuthUser(id: 'u1', email: 'a@b.com'), + throwOnLogout: true, + ); + final bloc = AuthBloc(repository: repo); + + await bloc.start(); + + await expectLater(bloc.logout(), throwsA(isA())); + expect(repo.logoutCalled, isTrue); + expect(bloc.state.status, AuthStatus.unauthenticated); + }, + ); +} diff --git a/apps/test/features/home/home_screen_test.dart b/apps/test/features/home/home_screen_test.dart new file mode 100644 index 0000000..b80cac0 --- /dev/null +++ b/apps/test/features/home/home_screen_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meeyao_qianwen/core/auth/session_store.dart'; +import 'package:meeyao_qianwen/app/app_theme.dart'; +import 'package:meeyao_qianwen/data/storage/local_kv_store.dart'; +import 'package:meeyao_qianwen/features/home/presentation/screens/home_screen.dart'; +import 'package:meeyao_qianwen/l10n/app_localizations.dart'; + +class _FakeSessionStore extends SessionStore { + _FakeSessionStore({required this.hasReadWelcomeValue}) + : super(LocalKvStore()); + + bool hasReadWelcomeValue; + bool setWelcomeReadCalled = false; + + @override + Future hasReadWelcome() async { + return hasReadWelcomeValue; + } + + @override + Future setWelcomeRead(bool value) async { + setWelcomeReadCalled = value; + } +} + +void main() { + testWidgets('history cards should use full available width', (tester) async { + final sessionStore = _FakeSessionStore(hasReadWelcomeValue: true); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: HomeScreen( + account: 'user@example.com', + sessionStore: sessionStore, + onLogout: () async {}, + ), + ), + ); + + await tester.pumpAndSettle(); + + final historyCard = find.byType(Card).first; + final cardWidth = tester.getSize(historyCard).width; + final viewportWidth = + tester.view.physicalSize.width / tester.view.devicePixelRatio; + + expect(cardWidth, viewportWidth); + }); +} diff --git a/apps/test/widget_test.dart b/apps/test/widget_test.dart index e412171..7c7caad 100644 --- a/apps/test/widget_test.dart +++ b/apps/test/widget_test.dart @@ -1,30 +1,10 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:meeyao_qianwen/main.dart'; +import 'package:meeyao_qianwen/app/app.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + testWidgets('app bootstraps', (WidgetTester tester) async { + await tester.pumpWidget(const EryaoApp()); + expect(find.byType(EryaoApp), findsOneWidget); }); } diff --git a/apps/tool/run-dev.sh b/apps/tool/run-dev.sh new file mode 100755 index 0000000..738994d --- /dev/null +++ b/apps/tool/run-dev.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BACKEND_URL="" +DEVICE_ARGS=() + +usage() { + cat < str: + database_url = config.database_url + if not database_url: + raise RuntimeError( + "DATABASE_URL is not configured. Set ERYAO_DATABASE__* values in .env." + ) + return database_url + + +def _build_config() -> dict[str, Any]: + section = alembic_config.get_section(alembic_config.config_ini_section) or {} + return {**section, "sqlalchemy.url": _get_database_url()} + + +def run_migrations_offline() -> None: + context.configure( + url=_get_database_url(), + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + compare_server_default=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def _do_run_migrations(connection: "Connection" | Any) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = async_engine_from_config( + _build_config(), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(_do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/20260402_0001_initial_llm_schema.py b/backend/alembic/versions/20260402_0001_initial_llm_schema.py new file mode 100644 index 0000000..8bcc602 --- /dev/null +++ b/backend/alembic/versions/20260402_0001_initial_llm_schema.py @@ -0,0 +1,162 @@ +"""initial llm/factory/system_agents schema + +Revision ID: 202604020001 +Revises: +Create Date: 2026-04-02 18:25:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "202604020001" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "llm_factory", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(length=50), nullable=False), + sa.Column("request_url", sa.String(length=255), nullable=False), + sa.Column("avatar", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True) + _enable_rls("llm_factory") + + op.create_table( + "llms", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("factory_id", sa.UUID(), nullable=False), + sa.Column("model_code", sa.String(length=50), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("model_code"), + ) + op.create_index("ix_llms_factory_id", "llms", ["factory_id"], unique=False) + op.create_index("ix_llms_model_code", "llms", ["model_code"], unique=True) + op.create_foreign_key( + "fk_llms_factory_id", + "llms", + "llm_factory", + ["factory_id"], + ["id"], + ondelete="RESTRICT", + ) + _enable_rls("llms") + + op.create_table( + "system_agents", + sa.Column("agent_type", sa.String(length=20), nullable=False), + sa.Column("llm_id", sa.UUID(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column( + "config", + postgresql.JSONB(astext_type=sa.Text()), + server_default="{}", + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("agent_type"), + ) + op.create_foreign_key( + "fk_system_agents_llm_id", + "system_agents", + "llms", + ["llm_id"], + ["id"], + ondelete="RESTRICT", + ) + _enable_rls("system_agents") + + op.execute("REVOKE ALL ON TABLE public.alembic_version FROM anon") + op.execute("REVOKE ALL ON TABLE public.alembic_version FROM authenticated") + + +def downgrade() -> None: + _drop_rls("system_agents") + op.drop_constraint("fk_system_agents_llm_id", "system_agents", type_="foreignkey") + op.drop_table("system_agents") + + _drop_rls("llms") + op.drop_constraint("fk_llms_factory_id", "llms", type_="foreignkey") + op.drop_index("ix_llms_model_code", table_name="llms") + op.drop_index("ix_llms_factory_id", table_name="llms") + op.drop_table("llms") + + _drop_rls("llm_factory") + op.drop_index("ix_llm_factory_name", table_name="llm_factory") + op.drop_table("llm_factory") + + +def _enable_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) + + +def _drop_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/src/app.py b/backend/src/app.py new file mode 100644 index 0000000..820e9b1 --- /dev/null +++ b/backend/src/app.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from core.config.settings import config +from core.http.errors import ApiProblemError +from core.http.response import build_problem_details +from core.logging import configure_logging, get_logger, log_service_banner +from services.base import close_registered_services, initialize_registered_services +from v1.router import router as v1_router + + +class HealthResponse(BaseModel): + status: str + + +configure_logging(config) +log_service_banner( + service_name=config.runtime.service_name, + environment=config.runtime.environment, +) + +logger = get_logger("api.app") +SERVICE_STARTUP_ORDER = ["redis", "supabase"] + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: + initialized, services = await initialize_registered_services(SERVICE_STARTUP_ORDER) + if not initialized: + logger.error("Service initialization failed, aborting startup") + raise RuntimeError("Service initialization failed") + + logger.info("Base services initialized", services=SERVICE_STARTUP_ORDER) + try: + yield + finally: + closed = await close_registered_services(services) + if not closed: + logger.warning("Failed to close all base services") + logger.info("Base services closed", services=SERVICE_STARTUP_ORDER) + + +app = FastAPI(lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origins=config.cors.allow_origins, + allow_credentials=config.cors.allow_credentials, + allow_methods=config.cors.allow_methods, + allow_headers=config.cors.allow_headers, +) +app.include_router(v1_router) + + +@app.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: + return HealthResponse(status="ok") + + +@app.exception_handler(ApiProblemError) +async def api_problem_exception_handler( + request: Request, + exc: ApiProblemError, +) -> JSONResponse: + problem = build_problem_details( + status_code=exc.status_code, + detail=exc.detail, + instance=request.url.path, + code=exc.code, + params=exc.params, + ) + return JSONResponse( + status_code=exc.status_code, + content=problem.model_dump(), + media_type="application/problem+json", + ) + + +@app.exception_handler(RequestValidationError) +async def request_validation_exception_handler( + request: Request, + exc: RequestValidationError, +) -> JSONResponse: + logger.warning( + "Request validation error", + path=request.url.path, + method=request.method, + errors=exc.errors(), + ) + problem = build_problem_details( + status_code=422, + detail="Invalid request", + instance=request.url.path, + code="REQUEST_VALIDATION_ERROR", + params={"errors": exc.errors()}, + ) + return JSONResponse( + status_code=422, + content=problem.model_dump(), + media_type="application/problem+json", + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + logger.exception( + "Unhandled error", + path=request.url.path, + method=request.method, + error_type=exc.__class__.__name__, + ) + problem = build_problem_details( + status_code=500, + detail="Internal Server Error", + instance=request.url.path, + code="INTERNAL_ERROR", + ) + return JSONResponse( + status_code=500, + content=problem.model_dump(), + media_type="application/problem+json", + ) diff --git a/backend/src/core/runtime/cli.py b/backend/src/core/runtime/cli.py index 9b289aa..f99b3d3 100644 --- a/backend/src/core/runtime/cli.py +++ b/backend/src/core/runtime/cli.py @@ -6,15 +6,14 @@ import sys from pathlib import Path from core.config.initial.init_data import initialize_data -from core.config.settings import config from core.logging import get_logger logger = get_logger("core.runtime.cli") def _resolve_alembic_path() -> Path: - project_root = Path(__file__).parents[3] - alembic_path = project_root / "alembic" / "alembic.ini" + project_root = Path(__file__).parents[4] + alembic_path = project_root / "backend" / "alembic" / "alembic.ini" if not alembic_path.exists(): raise FileNotFoundError(f"Alembic config not found at {alembic_path}") return alembic_path diff --git a/backend/src/schemas/agent/forwarded_props.py b/backend/src/schemas/agent/forwarded_props.py index 9e8250b..8d9e412 100644 --- a/backend/src/schemas/agent/forwarded_props.py +++ b/backend/src/schemas/agent/forwarded_props.py @@ -62,7 +62,6 @@ class ClientTimeContext(BaseModel): class RuntimeMode(str, Enum): CHAT = "chat" - AUTOMATION = "automation" class ForwardedPropsPayload(BaseModel): diff --git a/backend/src/services/base/supabase.py b/backend/src/services/base/supabase.py index 64a7e4f..5df94f9 100644 --- a/backend/src/services/base/supabase.py +++ b/backend/src/services/base/supabase.py @@ -4,8 +4,6 @@ import asyncio from typing import Any from supabase import create_client -from storage3.exceptions import StorageApiError - from core.config.settings import SupabaseSettings, config from .service_interface import BaseServiceProvider, register_service_instance @@ -183,9 +181,6 @@ class SupabaseService(BaseServiceProvider): def _is_bucket_not_found_error(self, exc: Exception) -> bool: """Check if the exception indicates a bucket was not found.""" - if isinstance(exc, StorageApiError): - message = str(exc).lower() - return "bucket" in message and "not found" in message message = str(exc).lower() return "bucket" in message and "not found" in message diff --git a/backend/src/v1/auth/automation_static_config.py b/backend/src/v1/auth/automation_static_config.py deleted file mode 100644 index ce0ab2d..0000000 --- a/backend/src/v1/auth/automation_static_config.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from functools import lru_cache -from pathlib import Path -import re -from typing import Any - -import yaml - -from schemas.domain.automation import AutomationJobConfig - -_CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") - - -def _automation_yaml_path(config_name: str) -> Path: - if not _CONFIG_NAME_PATTERN.fullmatch(config_name): - raise ValueError("invalid automation config name") - return ( - Path(__file__).resolve().parents[2] - / "core" - / "config" - / "static" - / "automation" - / f"{config_name}.yaml" - ) - - -@lru_cache(maxsize=16) -def load_static_automation_job_config(*, config_name: str) -> AutomationJobConfig: - path = _automation_yaml_path(config_name) - with path.open("r", encoding="utf-8") as file: - loaded: Any = yaml.safe_load(file) or {} - if not isinstance(loaded, dict): - raise ValueError(f"invalid automation config format: {path}") - return AutomationJobConfig.model_validate(loaded) diff --git a/backend/src/v1/auth/dependencies.py b/backend/src/v1/auth/dependencies.py index 1b96624..40c821c 100644 --- a/backend/src/v1/auth/dependencies.py +++ b/backend/src/v1/auth/dependencies.py @@ -1,27 +1,12 @@ from __future__ import annotations -from typing import Annotated -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession -from core.db import get_db from v1.auth.gateway import SupabaseAuthGateway -from v1.auth.registration_bootstrap import ( - RegistrationAutomationBootstrapService, - RegistrationBootstrapRepository, -) from v1.auth.service import AuthService -def get_auth_service( - session: Annotated[AsyncSession, Depends(get_db)], -) -> AuthService: - bootstrapper = RegistrationAutomationBootstrapService( - repository=RegistrationBootstrapRepository(session=session), - session=session, - ) +def get_auth_service() -> AuthService: return AuthService( gateway=SupabaseAuthGateway(), - registration_bootstrapper=bootstrapper, ) diff --git a/backend/src/v1/auth/dev_email_session.py b/backend/src/v1/auth/dev_email_session.py new file mode 100644 index 0000000..7c1cbc8 --- /dev/null +++ b/backend/src/v1/auth/dev_email_session.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Callable, cast + +from supabase import AuthError + +from core.http.errors import ApiProblemError +from core.logging import get_logger +from v1.auth.schemas import EmailSessionCreateRequest, SessionResponse + +logger = get_logger("v1.auth.dev_email_session") + + +def _auth_error(*, status_code: int, code: str, detail: str) -> ApiProblemError: + return ApiProblemError(status_code=status_code, code=code, detail=detail) + + +async def create_dev_email_session( + *, + request: EmailSessionCreateRequest, + client: Any, + admin_client: Any, + auth_unavailable_detail: str, + is_auth_upstream_unavailable: Callable[[AuthError], bool], + map_auth_response: Callable[[object, str, str], SessionResponse], +) -> SessionResponse: + generate_link_payload: dict[str, Any] = { + "type": "magiclink", + "email": request.email, + } + + try: + generate_link = cast(Any, admin_client.auth.admin.generate_link) + link_response = await asyncio.to_thread(generate_link, generate_link_payload) + except AuthError as exc: + logger.warning( + "Dev email session link generation failed", + error_type=type(exc).__name__, + ) + if is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=auth_unavailable_detail, + ) from exc + raise _auth_error( + status_code=401, + code="AUTH_VERIFICATION_CODE_INVALID", + detail="Invalid verification code", + ) from exc + + properties = getattr(link_response, "properties", None) + dev_token = str(getattr(properties, "email_otp", "")).strip() + if not dev_token: + raise _auth_error( + status_code=401, + code="AUTH_VERIFICATION_CODE_INVALID", + detail="Invalid verification code", + ) + + verify_payload: dict[str, Any] = { + "type": "email", + "email": request.email, + "token": dev_token, + } + try: + verify_otp = cast(Any, client.auth.verify_otp) + response = await asyncio.to_thread(verify_otp, verify_payload) + logger.info("Dev email session bypassed otp verification") + return map_auth_response( + response, + "Invalid verification code", + "AUTH_VERIFICATION_CODE_INVALID", + ) + except AuthError as exc: + logger.warning( + "Dev email session verification failed", + error_type=type(exc).__name__, + ) + if is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=auth_unavailable_detail, + ) from exc + raise _auth_error( + status_code=401, + code="AUTH_VERIFICATION_CODE_INVALID", + detail="Invalid verification code", + ) from exc diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index ec41f5e..76a6c4d 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import re import time from typing import Any, cast @@ -8,17 +9,19 @@ from pydantic import ValidationError from supabase import AuthError +from core.config.settings import config from core.http.errors import ApiProblemError from core.logging import get_logger from services.base.supabase import supabase_service +from v1.auth.dev_email_session import create_dev_email_session from v1.auth.schemas import ( AuthUser, + EmailSessionCreateRequest, OtpSendRequest, - PhoneSessionCreateRequest, SessionRefreshRequest, SessionResponse, UserByIdResponse, - UserByPhoneResponse, + UserByEmailResponse, ) from v1.auth.service import AuthServiceGateway @@ -40,7 +43,7 @@ class SupabaseAuthGateway(AuthServiceGateway): def __init__(self) -> None: self._user_lookup_cache_ttl_seconds: int = 60 self._user_lookup_cache_expires_at: float = 0.0 - self._users_by_phone: dict[str, Any] = {} + self._users_by_email: dict[str, Any] = {} self._users_by_id: dict[str, Any] = {} def _get_client(self) -> Any: @@ -52,7 +55,7 @@ class SupabaseAuthGateway(AuthServiceGateway): async def send_otp(self, request: OtpSendRequest) -> None: client = self._get_client() payload: dict[str, Any] = { - "phone": request.phone, + "email": request.email, "options": {"should_create_user": True}, } try: @@ -72,13 +75,23 @@ class SupabaseAuthGateway(AuthServiceGateway): detail="Too many requests", ) from exc - async def create_phone_session( - self, request: PhoneSessionCreateRequest + async def create_email_session( + self, request: EmailSessionCreateRequest ) -> SessionResponse: + if config.runtime.environment == "dev": + return await create_dev_email_session( + request=request, + client=self._get_client(), + admin_client=self._get_admin_client(), + auth_unavailable_detail=AUTH_UNAVAILABLE_DETAIL, + is_auth_upstream_unavailable=_is_auth_upstream_unavailable, + map_auth_response=_map_auth_response, + ) + client = self._get_client() payload: dict[str, Any] = { - "type": "sms", - "phone": request.phone, + "type": "email", + "email": request.email, "token": request.token, } try: @@ -90,7 +103,7 @@ class SupabaseAuthGateway(AuthServiceGateway): "AUTH_VERIFICATION_CODE_INVALID", ) except AuthError as exc: - logger.warning("Create phone session failed", error_type=type(exc).__name__) + logger.warning("Create email session failed", error_type=type(exc).__name__) if _is_auth_upstream_unavailable(exc): raise _auth_error( status_code=503, @@ -169,9 +182,9 @@ class SupabaseAuthGateway(AuthServiceGateway): detail="Invalid refresh token", ) from exc - async def get_user_by_phone(self, phone: str) -> UserByPhoneResponse: - normalized_phone = _normalize_phone(phone) - if not normalized_phone: + async def get_user_by_email(self, email: str) -> UserByEmailResponse: + normalized_email = _normalize_email(email) + if not normalized_email: raise _auth_error( status_code=404, code="AUTH_USER_NOT_FOUND", @@ -180,7 +193,7 @@ class SupabaseAuthGateway(AuthServiceGateway): await self._refresh_user_lookup_cache_if_needed() - user = self._users_by_phone.get(normalized_phone) + user = self._users_by_email.get(normalized_email) if user is None: raise _auth_error( status_code=404, @@ -188,21 +201,21 @@ class SupabaseAuthGateway(AuthServiceGateway): detail="User not found", ) - user_phone = _normalize_phone(getattr(user, "phone", "")) - if not user_phone: + user_email = _normalize_email(getattr(user, "email", "")) + if not user_email: raise _auth_error( status_code=404, code="AUTH_USER_NOT_FOUND", detail="User not found", ) - return UserByPhoneResponse( + return UserByEmailResponse( id=str(getattr(user, "id", "")), - phone=user_phone, + email=user_email, created_at=str(getattr(user, "created_at", "")), - phone_confirmed_at=( - str(getattr(user, "phone_confirmed_at", "")) - if getattr(user, "phone_confirmed_at", None) + email_confirmed_at=( + str(getattr(user, "email_confirmed_at", "")) + if getattr(user, "email_confirmed_at", None) else None ), ) @@ -233,53 +246,27 @@ class SupabaseAuthGateway(AuthServiceGateway): user_attrs = getattr(user, "user", user) resolved[normalized_user_id] = UserByIdResponse( id=str(getattr(user_attrs, "id", "")), - phone=getattr(user_attrs, "phone", None), + email=getattr(user_attrs, "email", None), created_at=str(getattr(user_attrs, "created_at", "")), - phone_confirmed_at=( - str(getattr(user_attrs, "phone_confirmed_at", "")) - if getattr(user_attrs, "phone_confirmed_at", None) + email_confirmed_at=( + str(getattr(user_attrs, "email_confirmed_at", "")) + if getattr(user_attrs, "email_confirmed_at", None) else None ), ) return resolved - async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]: - normalized_query = _normalize_phone_search_query(query) + async def search_user_ids_by_email(self, query: str, limit: int = 20) -> list[str]: + normalized_query = _normalize_email(query) if not normalized_query: return [] await self._refresh_user_lookup_cache_if_needed() - if normalized_query.startswith("+"): - matched_user = self._users_by_phone.get(normalized_query) - if matched_user is None: - return [] - user_id = str(getattr(matched_user, "id", "")) - return [user_id] if user_id else [] - - digits = _digits_only(normalized_query) - if not digits: + matched_user = self._users_by_email.get(normalized_query) + if matched_user is None: return [] - - matched_records: list[tuple[str, str]] = [] - for cached_phone, candidate in self._users_by_phone.items(): - candidate_digits = _digits_only(cached_phone) - if not candidate_digits.endswith(digits): - continue - user_id = str(getattr(candidate, "id", "")) - if user_id: - matched_records.append((cached_phone, user_id)) - - if not matched_records: - return [] - - unique_ids: list[str] = [] - for _, user_id in sorted(matched_records, key=lambda item: item[0]): - if user_id in unique_ids: - continue - unique_ids.append(user_id) - if len(unique_ids) >= max(1, limit): - break - return unique_ids + user_id = str(getattr(matched_user, "id", "")) + return [user_id] if user_id else [] async def _refresh_user_lookup_cache_if_needed(self) -> None: now = time.monotonic() @@ -288,17 +275,17 @@ class SupabaseAuthGateway(AuthServiceGateway): admin_client = self._get_admin_client() users = await asyncio.to_thread(_list_auth_users, admin_client) - users_by_phone: dict[str, Any] = {} + users_by_email: dict[str, Any] = {} users_by_id: dict[str, Any] = {} for candidate in users: candidate_id = str(getattr(candidate, "id", "")).strip() if candidate_id: users_by_id[candidate_id] = candidate - candidate_phone = _normalize_phone(getattr(candidate, "phone", "")) - if candidate_phone: - users_by_phone[candidate_phone] = candidate + candidate_email = _normalize_email(getattr(candidate, "email", "")) + if candidate_email: + users_by_email[candidate_email] = candidate self._users_by_id = users_by_id - self._users_by_phone = users_by_phone + self._users_by_email = users_by_email self._user_lookup_cache_expires_at = now + self._user_lookup_cache_ttl_seconds @@ -343,8 +330,8 @@ def _map_auth_response( detail=failure_message, ) - phone = _normalize_phone(getattr(user, "phone", None)) - if not phone: + email = _normalize_email(getattr(user, "email", None)) + if not email: raise _auth_error( status_code=401, code=failure_code, @@ -352,10 +339,10 @@ def _map_auth_response( ) try: - auth_user = AuthUser(id=str(user.id), phone=str(phone)) + auth_user = AuthUser(id=str(user.id), email=str(email)) except ValidationError as exc: logger.warning( - "Auth response returned invalid phone format", + "Auth response returned invalid email format", error_type=type(exc).__name__, ) raise _auth_error( @@ -393,38 +380,13 @@ def _list_auth_users(client: Any) -> list[Any]: return users -def _sanitize_phone_token(raw: object) -> str: - token = str(raw).strip() - for separator in (" ", "-", "(", ")"): - token = token.replace(separator, "") - return token +_EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") -def _normalize_phone(raw_phone: object) -> str | None: - phone = _sanitize_phone_token(raw_phone) - if not phone: +def _normalize_email(raw_email: object) -> str | None: + if not isinstance(raw_email, str): return None - if phone.startswith("00") and len(phone) > 2: - return f"+{phone[2:]}" - if phone.startswith("+"): - return phone - if phone.isdigit(): - return f"+{phone}" - return None - - -def _normalize_phone_search_query(raw_query: str) -> str | None: - query = _sanitize_phone_token(raw_query) - if not query: + email = raw_email.strip().lower() + if not _EMAIL_PATTERN.fullmatch(email): return None - if query.startswith("00") and len(query) > 2: - return f"+{query[2:]}" - if query.startswith("+"): - return query - if query.isdigit(): - return query - return None - - -def _digits_only(value: str) -> str: - return "".join(ch for ch in value if ch.isdigit()) + return email diff --git a/backend/src/v1/auth/registration_bootstrap.py b/backend/src/v1/auth/registration_bootstrap.py deleted file mode 100644 index d5de7f4..0000000 --- a/backend/src/v1/auth/registration_bootstrap.py +++ /dev/null @@ -1,239 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime, time, timedelta -from typing import Protocol -from uuid import UUID, uuid4 -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - -from sqlalchemy import select -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession - -from core.logging import get_logger -from models.automation_jobs import AutomationJob -from schemas.enums import AutomationJobStatus, MemoryType, ScheduleType -from models.profile import Profile -from schemas.domain.automation import AutomationJobConfig, ScheduleConfig -from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent -from schemas.shared.user import parse_profile_settings -from v1.auth.automation_static_config import load_static_automation_job_config -from v1.auth.schemas import RegistrationBootstrapRequest -from v1.memories.repository import SQLAlchemyMemoriesRepository - -logger = get_logger("v1.auth.registration_bootstrap") - - -class RegistrationBootstrapRepository: - def __init__(self, session: AsyncSession) -> None: - self._session = session - self._memories_repository = SQLAlchemyMemoriesRepository(session) - - async def get_profile_timezone(self, *, user_id: UUID) -> str: - stmt = select(Profile.settings).where(Profile.id == user_id) - settings = (await self._session.execute(stmt)).scalar_one_or_none() - parsed = parse_profile_settings( - settings if isinstance(settings, dict) else None - ) - return parsed.preferences.timezone - - async def insert_bootstrap_automation_job_if_absent( - self, - *, - owner_id: UUID, - bootstrap_key: str, - title: str, - config: AutomationJobConfig, - timezone_name: str, - next_run_at: datetime, - ) -> bool: - stmt = ( - insert(AutomationJob) - .values( - id=uuid4(), - owner_id=owner_id, - bootstrap_key=bootstrap_key, - title=title, - config=config.model_dump(mode="json"), - next_run_at=next_run_at, - timezone=timezone_name, - status=AutomationJobStatus.ACTIVE, - created_by=owner_id, - ) - .on_conflict_do_nothing( - index_elements=["owner_id", "bootstrap_key"], - index_where=AutomationJob.deleted_at.is_(None) - & AutomationJob.bootstrap_key.is_not(None), - ) - .returning(AutomationJob.id) - ) - inserted_id = (await self._session.execute(stmt)).scalar_one_or_none() - await self._session.flush() - return inserted_id is not None - - async def upsert_initial_memory( - self, - *, - owner_id: UUID, - memory_type: MemoryType, - content: dict, - ) -> bool: - return await self._memories_repository.create_if_absent( - owner_id=owner_id, - memory_type=memory_type, - content=content, - ) - - -class RegistrationBootstrapper(Protocol): - async def ensure_user_automation_jobs(self, *, user_id: str | UUID) -> None: ... - - -class RegistrationBootstrapRepositoryLike(Protocol): - async def get_profile_timezone(self, *, user_id: UUID) -> str: ... - - async def insert_bootstrap_automation_job_if_absent( - self, - *, - owner_id: UUID, - bootstrap_key: str, - title: str, - config: AutomationJobConfig, - timezone_name: str, - next_run_at: datetime, - ) -> bool: ... - - async def upsert_initial_memory( - self, - *, - owner_id: UUID, - memory_type: MemoryType, - content: dict, - ) -> bool: ... - - -class SessionLike(Protocol): - async def commit(self) -> None: ... - - async def rollback(self) -> None: ... - - -def compute_first_run_at_utc( - *, - now_utc: datetime, - timezone_name: str, - schedule: ScheduleConfig, -) -> datetime: - try: - timezone_obj = ZoneInfo(timezone_name) - except ZoneInfoNotFoundError: - timezone_obj = ZoneInfo("UTC") - - local_now = now_utc.astimezone(timezone_obj) - run_clock = time( - hour=schedule.run_at.hour, - minute=schedule.run_at.minute, - tzinfo=timezone_obj, - ) - - if schedule.type == ScheduleType.DAILY: - candidate_local = datetime.combine(local_now.date(), run_clock) - if candidate_local <= local_now: - candidate_local = candidate_local + timedelta(days=1) - return candidate_local.astimezone(UTC) - - weekdays = schedule.weekdays or [] - if not weekdays: - raise ValueError("weekly schedule requires weekdays") - - normalized_weekdays = sorted(set(weekdays)) - for day_offset in range(0, 8): - candidate_day = local_now.date() + timedelta(days=day_offset) - if candidate_day.isoweekday() not in normalized_weekdays: - continue - candidate_local = datetime.combine(candidate_day, run_clock) - if candidate_local > local_now: - return candidate_local.astimezone(UTC) - - fallback_day = local_now.date() + timedelta(days=7) - while fallback_day.isoweekday() not in normalized_weekdays: - fallback_day = fallback_day + timedelta(days=1) - fallback_local = datetime.combine(fallback_day, run_clock) - return fallback_local.astimezone(UTC) - - -class RegistrationAutomationBootstrapService: - def __init__( - self, - *, - repository: RegistrationBootstrapRepositoryLike, - session: SessionLike, - ) -> None: - self._repository = repository - self._session = session - - async def ensure_user_automation_jobs(self, *, user_id: str | UUID) -> None: - request = RegistrationBootstrapRequest.model_validate({"user_id": user_id}) - owner_id = request.user_id - timezone_name = await self._repository.get_profile_timezone(user_id=owner_id) - - definitions = [ - { - "bootstrap_key": "memory_extraction", - "config_name": "memory_extraction", - "title": "记忆推送", - } - ] - - try: - inserted_any = False - created_or_updated_memory = False - - user_initialized = await self._repository.upsert_initial_memory( - owner_id=owner_id, - memory_type=MemoryType.USER, - content=UserMemoryContent().model_dump(mode="json"), - ) - work_initialized = await self._repository.upsert_initial_memory( - owner_id=owner_id, - memory_type=MemoryType.WORK, - content=WorkProfileContent().model_dump(mode="json"), - ) - created_or_updated_memory = user_initialized or work_initialized - - for definition in definitions: - bootstrap_key = str(definition["bootstrap_key"]) - job_config = load_static_automation_job_config( - config_name=str(definition["config_name"]) - ) - schedule = job_config.schedule - if schedule is None: - raise ValueError( - f"bootstrap job {bootstrap_key} has no schedule configured" - ) - next_run_at = compute_first_run_at_utc( - now_utc=datetime.now(UTC), - timezone_name=timezone_name, - schedule=schedule, - ) - inserted = ( - await self._repository.insert_bootstrap_automation_job_if_absent( - owner_id=owner_id, - bootstrap_key=bootstrap_key, - title=str(definition["title"]), - config=job_config, - timezone_name=timezone_name, - next_run_at=next_run_at, - ) - ) - inserted_any = inserted_any or inserted - if inserted_any or created_or_updated_memory: - await self._session.commit() - logger.info( - "user automation jobs bootstrapped", - user_id=user_id, - timezone=timezone_name, - memory_initialized=created_or_updated_memory, - ) - except Exception: - await self._session.rollback() - raise diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index 2cad943..e2d30c5 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -6,8 +6,8 @@ from core.config.settings import config from v1.auth.rate_limit import enforce_rate_limit from v1.auth.dependencies import get_auth_service from v1.auth.schemas import ( + EmailSessionCreateRequest, OtpSendRequest, - PhoneSessionCreateRequest, SessionDeleteRequest, SessionRefreshRequest, SessionResponse, @@ -26,8 +26,8 @@ async def send_otp( ) -> Response: client_ip = _client_ip(request) await enforce_rate_limit( - scope="otp_send_phone", - identifier=payload.phone, + scope="otp_send_email", + identifier=payload.email.lower(), limit=3, window_seconds=60, ) @@ -41,26 +41,26 @@ async def send_otp( return Response(status_code=204) -@router.post("/phone-session", response_model=SessionResponse) -async def create_phone_session( - payload: PhoneSessionCreateRequest, +@router.post("/email-session", response_model=SessionResponse) +async def create_email_session( + payload: EmailSessionCreateRequest, request: Request, service: AuthService = Depends(get_auth_service), ) -> SessionResponse: client_ip = _client_ip(request) await enforce_rate_limit( - scope="phone_session_phone", - identifier=payload.phone, + scope="session_email", + identifier=payload.email.lower(), limit=6, window_seconds=300, ) await enforce_rate_limit( - scope="phone_session_ip", + scope="session_ip", identifier=client_ip, limit=20, window_seconds=300, ) - return await service.create_phone_session(payload) + return await service.create_email_session(payload) @router.post("/sessions/refresh", response_model=SessionResponse) diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index 446283a..3a3240a 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -1,24 +1,22 @@ from __future__ import annotations -from uuid import UUID - from pydantic import BaseModel, ConfigDict, Field SUPABASE_PASSWORD_MIN_LENGTH = 6 -SUPABASE_PHONE_PATTERN = r"^\+[1-9]\d{7,14}$" +SUPABASE_EMAIL_PATTERN = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" class OtpSendRequest(BaseModel): model_config = ConfigDict(extra="forbid") - phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + email: str = Field(pattern=SUPABASE_EMAIL_PATTERN) -class PhoneSessionCreateRequest(BaseModel): +class EmailSessionCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") - phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) - token: str = Field(pattern=r"^\d{6}$") + email: str = Field(pattern=SUPABASE_EMAIL_PATTERN) + token: str = Field(min_length=6, max_length=6) class SessionRefreshRequest(BaseModel): @@ -31,7 +29,7 @@ class SessionDeleteRequest(BaseModel): class AuthUser(BaseModel): id: str - phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + email: str = Field(pattern=SUPABASE_EMAIL_PATTERN) class SessionResponse(BaseModel): @@ -42,25 +40,19 @@ class SessionResponse(BaseModel): user: AuthUser -class UserByPhoneResponse(BaseModel): +class UserByEmailResponse(BaseModel): id: str - phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + email: str = Field(pattern=SUPABASE_EMAIL_PATTERN) created_at: str - phone_confirmed_at: str | None = None + email_confirmed_at: str | None = None class UserByIdResponse(BaseModel): id: str - phone: str | None = None + email: str | None = None created_at: str - phone_confirmed_at: str | None = None + email_confirmed_at: str | None = None class OtpSendResponse(BaseModel): - phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) - - -class RegistrationBootstrapRequest(BaseModel): - model_config = ConfigDict(extra="forbid") - - user_id: UUID + email: str = Field(pattern=SUPABASE_EMAIL_PATTERN) diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py index d16e2ce..0b0cedd 100644 --- a/backend/src/v1/auth/service.py +++ b/backend/src/v1/auth/service.py @@ -3,8 +3,8 @@ from __future__ import annotations from typing import Protocol from v1.auth.schemas import ( + EmailSessionCreateRequest, OtpSendRequest, - PhoneSessionCreateRequest, SessionRefreshRequest, SessionResponse, ) @@ -14,8 +14,8 @@ class AuthServiceGateway(Protocol): async def send_otp(self, request: OtpSendRequest) -> None: raise NotImplementedError - async def create_phone_session( - self, request: PhoneSessionCreateRequest + async def create_email_session( + self, request: EmailSessionCreateRequest ) -> SessionResponse: raise NotImplementedError @@ -28,36 +28,23 @@ class AuthServiceGateway(Protocol): class AuthService: _gateway: AuthServiceGateway - _registration_bootstrapper: RegistrationBootstrapper | None def __init__( self, gateway: AuthServiceGateway, - registration_bootstrapper: "RegistrationBootstrapper | None" = None, ) -> None: self._gateway = gateway - self._registration_bootstrapper = registration_bootstrapper async def send_otp(self, request: OtpSendRequest) -> None: await self._gateway.send_otp(request) - async def create_phone_session( - self, request: PhoneSessionCreateRequest + async def create_email_session( + self, request: EmailSessionCreateRequest ) -> SessionResponse: - response = await self._gateway.create_phone_session(request) - if self._registration_bootstrapper is not None: - await self._registration_bootstrapper.ensure_user_automation_jobs( - user_id=response.user.id - ) - return response + return await self._gateway.create_email_session(request) async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: return await self._gateway.refresh_session(request) async def delete_session(self, refresh_token: str | None) -> None: await self._gateway.delete_session(refresh_token) - - -class RegistrationBootstrapper(Protocol): - async def ensure_user_automation_jobs(self, *, user_id: str) -> None: - raise NotImplementedError diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py new file mode 100644 index 0000000..ca71b16 --- /dev/null +++ b/backend/src/v1/router.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from v1.auth.router import router as auth_router + + +router = APIRouter(prefix="/api/v1") +router.include_router(auth_router) diff --git a/backend/tests/unit/test_app_lifespan.py b/backend/tests/unit/test_app_lifespan.py new file mode 100644 index 0000000..02c3581 --- /dev/null +++ b/backend/tests/unit/test_app_lifespan.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import pytest + +import app as app_module + + +@pytest.mark.asyncio +async def test_lifespan_uses_registered_services( + monkeypatch: pytest.MonkeyPatch, +) -> None: + initialized_services = [object(), object()] + calls: dict[str, object] = {} + + async def _fake_initialize(service_names: list[str]) -> tuple[bool, list[object]]: + calls["init_names"] = service_names + return True, initialized_services + + async def _fake_close(services: list[object]) -> bool: + calls["close_services"] = services + return True + + monkeypatch.setattr(app_module, "initialize_registered_services", _fake_initialize) + monkeypatch.setattr(app_module, "close_registered_services", _fake_close) + + context = app_module.lifespan(app_module.app) + await context.__aenter__() + await context.__aexit__(None, None, None) + + assert calls["init_names"] == ["redis", "supabase"] + assert calls["close_services"] == initialized_services + + +@pytest.mark.asyncio +async def test_lifespan_raises_when_initialization_failed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_initialize(_: list[str]) -> tuple[bool, list[object]]: + return False, [] + + monkeypatch.setattr(app_module, "initialize_registered_services", _fake_initialize) + + context = app_module.lifespan(app_module.app) + with pytest.raises(RuntimeError, match="Service initialization failed"): + await context.__aenter__() diff --git a/backend/tests/unit/test_auth_gateway_dev_session.py b/backend/tests/unit/test_auth_gateway_dev_session.py new file mode 100644 index 0000000..0555817 --- /dev/null +++ b/backend/tests/unit/test_auth_gateway_dev_session.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +import v1.auth.gateway as gateway_module +from v1.auth.gateway import SupabaseAuthGateway +from v1.auth.schemas import AuthUser, EmailSessionCreateRequest, SessionResponse + + +@pytest.mark.asyncio +async def test_create_email_session_uses_dev_bypass( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = SupabaseAuthGateway() + request = EmailSessionCreateRequest(email="test@example.com", token="123456") + expected = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=AuthUser(id="user-1", email="test@example.com"), + ) + calls: dict[str, object] = {} + + async def _fake_create_dev_email_session(**kwargs: object) -> SessionResponse: + calls.update(kwargs) + return expected + + monkeypatch.setattr(gateway_module.config.runtime, "environment", "dev") + monkeypatch.setattr( + gateway_module, "create_dev_email_session", _fake_create_dev_email_session + ) + monkeypatch.setattr(gateway, "_get_client", lambda: SimpleNamespace(name="anon")) + monkeypatch.setattr( + gateway, + "_get_admin_client", + lambda: SimpleNamespace(name="service-role"), + ) + + response = await gateway.create_email_session(request) + + assert response == expected + assert calls["request"] == request + assert calls["auth_unavailable_detail"] == gateway_module.AUTH_UNAVAILABLE_DETAIL + + +@pytest.mark.asyncio +async def test_create_email_session_uses_verify_otp_in_non_dev( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = SupabaseAuthGateway() + request = EmailSessionCreateRequest(email="test@example.com", token="123456") + captured_payload: dict[str, str] = {} + + def _verify_otp(payload: dict[str, str]) -> SimpleNamespace: + captured_payload.update(payload) + return SimpleNamespace( + session=SimpleNamespace( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + ), + user=SimpleNamespace(id="user-2", email="test@example.com"), + ) + + monkeypatch.setattr(gateway_module.config.runtime, "environment", "prod") + monkeypatch.setattr( + gateway, + "_get_client", + lambda: SimpleNamespace(auth=SimpleNamespace(verify_otp=_verify_otp)), + ) + + response = await gateway.create_email_session(request) + + assert captured_payload == { + "type": "email", + "email": "test@example.com", + "token": "123456", + } + assert response.user.email == "test@example.com" diff --git a/docs/protocols/auth/session-auth-protocol.md b/docs/protocols/auth/session-auth-protocol.md new file mode 100644 index 0000000..eb190f4 --- /dev/null +++ b/docs/protocols/auth/session-auth-protocol.md @@ -0,0 +1,131 @@ +# Session Auth Protocol (Frontend <-> Backend) + +This protocol defines login, refresh, and logout contract for Eryao Flutter app. + +Protocol verification status: + +- Last audited against backend source: `backend/src/v1/auth/router.py`, `backend/src/v1/auth/schemas.py`, `backend/src/v1/router.py` +- Frontend mapping source: `apps/lib/features/auth/data/apis/auth_api.dart` +- Current status: aligned + +## Base URL + +- Runtime injected by Flutter `--dart-define=BACKEND_URL=...` +- Fallback: + - Android emulator: `http://10.0.2.2:5775` + - Others: `http://localhost:5775` + +## Transport + +- Content type: `application/json` +- Error format: RFC7807 with extension fields `code`, `params` + +## Route audit (current backend) + +From `backend/src/v1/auth/router.py`: + +- `POST /auth/otp/send` (204) +- `POST /auth/email-session` (200, `SessionResponse`) +- `POST /auth/sessions/refresh` (200, `SessionResponse`) +- `DELETE /auth/sessions` (204) + +Gateway error codes from `backend/src/v1/auth/gateway.py`: + +- `AUTH_SERVICE_UNAVAILABLE` +- `AUTH_TOO_MANY_REQUESTS` +- `AUTH_VERIFICATION_CODE_INVALID` +- `AUTH_REFRESH_TOKEN_INVALID` +- `AUTH_REFRESH_TOKEN_MISSING` + +## Frontend route mapping + +- Send OTP: `POST /api/v1/auth/otp/send` +- Login with OTP: `POST /api/v1/auth/email-session` +- Refresh session: `POST /api/v1/auth/sessions/refresh` +- Logout: `DELETE /api/v1/auth/sessions` + +## Payload contract + +### Send OTP + +Request: + +```json +{ "email": "user@example.com" } +``` + +Validation (backend): + +- `email` must match `SUPABASE_EMAIL_PATTERN` + +Response: `204 No Content` + +### Create session + +Request: + +```json +{ "email": "user@example.com", "token": "123456" } +``` + +Validation (backend): + +- `email` must match `SUPABASE_EMAIL_PATTERN` +- `token` must be exactly 6 chars + +Response: + +```json +{ + "access_token": "...", + "refresh_token": "...", + "expires_in": 3600, + "token_type": "bearer", + "user": { + "id": "uuid", + "email": "user@example.com" + } +} +``` + +### Refresh session + +Request: + +```json +{ "refresh_token": "..." } +``` + +Validation (backend): + +- `refresh_token` min length: 1 + +Response: same as create session. + +### Logout + +Request: + +```json +{ "refresh_token": "..." } +``` + +Validation (backend): + +- `refresh_token` min length: 1 + +Response: `204 No Content` + +## Global auth state requirements + +- Auth must be managed by single global `AuthBloc` instance. +- App startup must attempt refresh if refresh token exists. +- On refresh failure (`401` + `AUTH_REFRESH_TOKEN_INVALID`), clear local session and route to login. +- Login success must persist access token + refresh token + display email. +- Logout must call backend route then clear local session. +- For protected requests, when backend returns `401`, frontend network layer must trigger global auth invalidation callback (single chain), not feature-level token clearing. + +## Login identity mode + +- Current app version supports **email OTP login only**. +- Phone registration/login is removed from frontend flow. diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md new file mode 100644 index 0000000..847129c --- /dev/null +++ b/docs/protocols/common/http-error-codes.md @@ -0,0 +1,20 @@ +# HTTP Error Codes + +This document is the source of truth for backend RFC7807 `code` values consumed by frontend. + +## Auth + +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `AUTH_SERVICE_UNAVAILABLE` | 503 | Auth upstream unavailable | Show retry message and allow retry | +| `AUTH_TOO_MANY_REQUESTS` | 429 | OTP request throttled | Show wait message | +| `AUTH_VERIFICATION_CODE_INVALID` | 401 | Invalid OTP code | Prompt user to re-enter code | +| `AUTH_REFRESH_TOKEN_INVALID` | 401 | Invalid/expired refresh token | Clear local session and return login | +| `AUTH_REFRESH_TOKEN_MISSING` | 401 | Refresh token missing on logout | Treat as local logout and clear session | +| `AUTH_USER_NOT_FOUND` | 404 | User not found | Show not-found message where applicable | + +Compatibility strategy: + +- Additive changes only for new codes. +- Existing codes must keep semantic meaning. +- Frontend must map by `code`, not by `detail` text. diff --git a/infra/docker/supabase/docker-compose.yml b/infra/docker/supabase/docker-compose.yml index add41e1..1901bfe 100644 --- a/infra/docker/supabase/docker-compose.yml +++ b/infra/docker/supabase/docker-compose.yml @@ -58,6 +58,8 @@ services: GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_EXP: 3600 GOTRUE_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" + GOTRUE_EXTERNAL_PHONE_ENABLED: "false" GOTRUE_MAILER_AUTOCONFIRM: "false" GOTRUE_SMTP_ADMIN_EMAIL: dev@example.com GOTRUE_SMTP_HOST: localhost diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index a7ce7de..2bf9ae3 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" SESSION_NAME="${SESSION_NAME:-eryao-dev}" ENV_FILE="$ROOT_DIR/.env" +ENV_LOADER="$ROOT_DIR/infra/scripts/lib/env.sh" usage() { echo "Usage: $0 {start|stop|restart}" @@ -16,12 +17,9 @@ usage() { } load_env_if_exists() { - if [ -f "$ENV_FILE" ]; then - set -a - # shellcheck disable=SC1090 - . "$ENV_FILE" - set +a - fi + # shellcheck disable=SC1090 + . "$ENV_LOADER" + load_env_file "$ENV_FILE" } is_port_in_use() { @@ -152,16 +150,14 @@ start() { echo "Warning: ERYAO_DEEPSEEK__API_KEY is empty; deepseek calls may fail." >&2 fi - WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=web uv run uvicorn backend.src.app:app --host ${ERYAO_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers ${ERYAO_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" + WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=web uv run uvicorn app:app --host ${ERYAO_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers ${ERYAO_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" WORKER_AGENT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-agent uv run taskiq worker core.taskiq.app:worker_agent_broker core.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY:-2}" - WORKER_GENERAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-general uv run taskiq worker core.taskiq.app:worker_general_broker core.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY:-1}" echo "Starting tmux web process in session '$SESSION_NAME'..." tmux new-session -d -s "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" tmux new-window -t "$SESSION_NAME" -n worker-agent "bash -lc \"$WORKER_AGENT_CMD; echo '[worker-agent] exited'; exec bash\"" - tmux new-window -t "$SESSION_NAME" -n worker-general "bash -lc \"$WORKER_GENERAL_CMD; echo '[worker-general] exited'; exec bash\"" echo "" echo "=== App Started ===" @@ -187,7 +183,7 @@ stop() { echo "Checking for orphaned processes..." - kill_matching_processes "uvicorn" "uv run uvicorn backend.src.app:app" + kill_matching_processes "uvicorn" "uv run uvicorn app:app" kill_listening_processes "port ${WEB_PORT} listeners" "$WEB_PORT" diff --git a/infra/scripts/dev-migrate.sh b/infra/scripts/dev-migrate.sh new file mode 100755 index 0000000..a749a9f --- /dev/null +++ b/infra/scripts/dev-migrate.sh @@ -0,0 +1,47 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +ENV_FILE="$ROOT_DIR/.env" +ENV_LOADER="$ROOT_DIR/infra/scripts/lib/env.sh" + +usage() { + echo "Usage: $0 {migrate|init-data|bootstrap}" + echo "" + echo "Commands:" + echo " migrate Run database migrations only" + echo " init-data Initialize seed data only" + echo " bootstrap Run migrations + init-data" + echo "" + echo "Note: Requires redis service running (docker compose up -d redis)" + exit 1 +} + +if [ ! -f "$ENV_FILE" ]; then + echo "Error: env file not found at $ENV_FILE" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +. "$ENV_LOADER" +load_env_file "$ENV_FILE" + +cd "$ROOT_DIR" + +case "${1:-}" in + migrate) + echo "=== Running Migrations ===" + PYTHONPATH=backend/src uv run python -m core.runtime.cli migrate + ;; + init-data) + echo "=== Running Init Data ===" + PYTHONPATH=backend/src uv run python -m core.runtime.cli init-data + ;; + bootstrap) + echo "=== Running Bootstrap ===" + PYTHONPATH=backend/src uv run python -m core.runtime.cli bootstrap + ;; + *) + usage + ;; +esac diff --git a/infra/scripts/lib/env.sh b/infra/scripts/lib/env.sh new file mode 100644 index 0000000..cda1807 --- /dev/null +++ b/infra/scripts/lib/env.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +load_env_file() { + local env_file="$1" + if [ ! -f "$env_file" ]; then + return 0 + fi + + while IFS= read -r raw_line || [ -n "$raw_line" ]; do + local line key value + line="${raw_line%$'\r'}" + + [[ -z "${line//[[:space:]]/}" ]] && continue + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" != *"="* ]] && continue + + key="${line%%=*}" + value="${line#*=}" + key="${key#${key%%[![:space:]]*}}" + key="${key%${key##*[![:space:]]}}" + + if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + continue + fi + + value="${value#${value%%[![:space:]]*}}" + value="${value%${value##*[![:space:]]}}" + + if [[ "$value" =~ ^\".*\"$ ]]; then + value="${value:1:${#value}-2}" + elif [[ "$value" =~ ^\'.*\'$ ]]; then + value="${value:1:${#value}-2}" + fi + + export "$key=$value" + done < "$env_file" +} diff --git a/pyproject.toml b/pyproject.toml index 56a8abe..39f40ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,7 @@ dependencies = [ "redis==7.2.1", "sqlalchemy[asyncio]==2.0.48", "structlog==25.5.0", - "supabase==2.21.0", - "storage3==0.8.0", + "supabase==2.28.0", "taskiq==0.12.1", "taskiq-redis==1.2.2", "uvicorn[standard]==0.41.0", @@ -39,6 +38,7 @@ default = true testpaths = ["backend/tests"] addopts = "-q --import-mode=importlib" asyncio_mode = "auto" +pythonpath = ["backend/src"] [dependency-groups] dev = [