commit 61017f1c395552ad91c62ff10ec116d695fc083e Author: SkyJourney Date: Thu May 14 12:51:05 2026 +0800 chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板 Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库 7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系 5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter 通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..419b016 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# 统一行尾为 LF(跨平台 CI 安全) +* text=auto eol=lf + +# 二进制文件不做行尾处理 +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ttf binary +*.otf binary +*.ico binary +*.jar binary +*.zip binary +*.apk binary +*.aab binary + +# Android keystore(万一误入,保持二进制) +*.jks binary +*.keystore binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0225f9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +# ═══════════════════════════════════════════════════════════════════ +# Flutter / Dart +# ═══════════════════════════════════════════════════════════════════ + +# Dart tooling +.dart_tool/ +.pub-cache/ +.pub/ +**/doc/api/ + +# Flutter build cache +/build/ +/coverage/ +**/ios/Flutter/.last_build_id + +# Flutter plugin metadata (auto-generated on `flutter pub get`) +.flutter-plugins +.flutter-plugins-dependencies + +# Symbolication / obfuscation maps +app.*.symbols +app.*.map.json + +# ═══════════════════════════════════════════════════════════════════ +# Android +# ═══════════════════════════════════════════════════════════════════ + +# Gradle +/android/.gradle/ +/android/local.properties # 本机 SDK 路径,不应提交 +/android/captures/ + +# Build outputs (Flavor 构建产物) +/android/app/debug/ +/android/app/profile/ +/android/app/release/ + +# Android 签名密钥(严禁提交) +*.jks +*.keystore +key.properties +/android/app/google-services.json # 按需:若使用 Firebase 则配置 CI secret + +# ═══════════════════════════════════════════════════════════════════ +# iOS +# ═══════════════════════════════════════════════════════════════════ + +# CocoaPods(pod install 后自动生成) +/ios/Pods/ +/ios/.symlinks/ + +# Xcode 自动生成 +/ios/Flutter/Generated.xcconfig +/ios/Flutter/flutter_export_environment.sh +/ios/Runner/GeneratedPluginRegistrant.swift +/ios/Runner/GeneratedPluginRegistrant.h +/ios/Runner/GeneratedPluginRegistrant.m + +# ═══════════════════════════════════════════════════════════════════ +# dart-defines(敏感配置) +# ═══════════════════════════════════════════════════════════════════ + +# prod.json 包含真实 Sentry DSN / RSA 公钥,由 CI secret 注入,禁止提交 +dart-defines/prod.json + +# dev.json / staging.json 含占位符,可提交(无真实密钥) +# dart-defines/dev.json ← 不忽略 +# dart-defines/staging.json ← 不忽略 + +# ═══════════════════════════════════════════════════════════════════ +# IDE +# ═══════════════════════════════════════════════════════════════════ + +# IntelliJ / Android Studio +*.iml +*.ipr +*.iws +.idea/ + +# VS Code(可按团队习惯解注释) +# .vscode/ + +# ═══════════════════════════════════════════════════════════════════ +# OS +# ═══════════════════════════════════════════════════════════════════ + +.DS_Store +Thumbs.db +Desktop.ini +*.swp +*.orig +.history/ + +# ═══════════════════════════════════════════════════════════════════ +# Misc +# ═══════════════════════════════════════════════════════════════════ + +*.log +*.class +*.pyc +.atom/ +.buildlog/ +.svn/ +.swiftpm/ +migrate_working_dir/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..6738224 --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: android + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: ios + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f3c8619 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md — sunny_mochi 脚手架项目规范 + +## 项目简介 + +`sunny_mochi` 是企业级 Flutter APP 脚手架模板,包含: +- Flavor 三包体系(dev/staging/prod) +- 完整 Core 基础设施层(网络/存储/崩溃/主题/观测性) +- 认证骨架(Auth Feature) +- 开发者工具面板(Dev Panel) +- 本地错误日志 + 上报 + +## 目录结构 + +``` +lib/ +├── main.dart # 6 步启动序列 +├── app.dart # AppRoot → MaterialApp.router +├── i18n/ # Slang 翻译文件 +├── core/ +│ ├── config/ # Env / ApiConfig / ApiPaths +│ ├── router/ # GoRouter + TypedRoutes +│ ├── network/ # Dio 7 拦截器 + MockAdapter +│ ├── storage/ # Drift + SQLCipher + SecureStorage +│ ├── crash/ # CrashReporter 3 步生命周期 +│ ├── error/ # sealed Failure 层级 +│ ├── theme/ # 5 色板 + 深色模式 +│ ├── observability/ # Talker + Sentry +│ ├── sync/ # Offline-First 同步框架 +│ ├── crypto/ # RSA 加密工具 +│ ├── extensions/ # BuildContext / String / num / DateTime +│ ├── utils/ # 验证器 / 密码强度 / SMS 倒计时 +│ ├── data/ # FreshnessPolicy 缓存策略 +│ └── widgets/ # 通用 UI 组件库 +└── features/ + ├── auth/ # 认证 Feature(完整 Clean Architecture) + ├── dev_panel/ # Talker 调试面板(Internal 包) + └── error_report/ # 本地错误日志查看 + 上报 +``` + +## 新项目初始化清单 + +1. **替换包名**:`sunny_mochi` → 你的包名(android/app/build.gradle.kts + pubspec.yaml + import) +2. **填入 API 路径**:`lib/core/config/api_paths.dart` 中所有空字符串 +3. **配置 Flavor**:`android/app/build.gradle.kts` applicationId + 签名配置 +4. **填入 Sentry DSN**:`dart-defines/prod.json` +5. **填入 RSA 公钥**:`dart-defines/dev.json` 的 `rsaPublicKey` 字段 +6. **替换 i18n 字符串**:`lib/i18n/strings_zh_CN.i18n.json` 按需扩展 +7. **添加业务路由**:`lib/core/router/routes.dart` 新增 TypedGoRoute +8. **实现 Tab 页面**:替换 HomeRoute / MineRoute 的 placeholder build + +## 零容忍规则 + +| 规则 | 正确 | 禁止 | +|------|------|------| +| API 路径 | `ApiPaths.xxx` | 字面量字符串 | +| 错误处理 | `ExceptionMapper.fromUnknown()` | 散落的 `catch(e)` 直接显示 | +| Token 存储 | `SecureStorage`(SoT) | 普通 SharedPreferences | +| DB 加密 | SQLCipher(默认) | 无加密 sqflite | +| 日志 | `appTalker.xxx()` | `print()` / `debugPrint()` | +| 路由跳转 | `LoginRoute().go(context)` | `Navigator.push()` | + +## 常用命令 + +```bash +# 代码生成 +make gen + +# i18n 生成 +make gen-i18n + +# 运行(dev 包) +make run-dev + +# 构建 staging APK +make build-staging + +# 静态分析 +flutter analyze + +# 测试 +flutter test --coverage +``` + +## 关键技术决策 + +- **SQLCipher**:企业合规要求加密存储,部署后无法无损迁移到无加密 +- **7 拦截器栈**:Token 刷新互斥(Completer)+ 证书绑定是安全基础,不可简化 +- **sealed Failure**:统一错误分类,UI / 日志 / Sentry 三层复用同一模型 +- **schemaVersion=1**:脚手架全新 DB,不携带任何历史迁移负担 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0e0febb --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +FLUTTER := flutter + +.PHONY: help run-dev run-staging build-staging build-prod build-apk-dev clean gen gen-i18n + +help: + @echo "Flutter Flavor 构建命令:" + @echo "" + @echo " make run-dev 开发版调试运行(热重载,连 dev 服务器)" + @echo " make run-staging 测试版 Release 运行(连 test 服务器)" + @echo " make build-staging 打测试版 APK(给测试团队安装)" + @echo " make build-prod 打正式版 AAB(上 Play Store / 内部分发)" + @echo " make build-apk-dev 打开发版 APK(快速真机验证)" + @echo " make clean 清除构建缓存" + @echo " make gen 代码生成(build_runner)" + @echo " make gen-i18n 生成 i18n ARB → Dart" + @echo "" + @echo "注意:build-prod 需先复制 dart-defines/prod.json.template 为 dart-defines/prod.json 并填入真实密钥" + +run-dev: + $(FLUTTER) run --flavor dev \ + --dart-define-from-file=dart-defines/dev.json + +run-staging: + $(FLUTTER) run --flavor staging --release \ + --dart-define-from-file=dart-defines/staging.json + +build-apk-dev: + $(FLUTTER) build apk --flavor dev --debug \ + --dart-define-from-file=dart-defines/dev.json + +build-staging: + $(FLUTTER) build apk --flavor staging --release \ + --dart-define-from-file=dart-defines/staging.json + @echo "APK 路径:build/app/outputs/flutter-apk/app-staging-release.apk" + +build-prod: + @test -f dart-defines/prod.json || \ + (echo "ERROR: dart-defines/prod.json 不存在!请从 prod.json.template 复制并填入真实密钥。" && exit 1) + $(FLUTTER) build appbundle --flavor prod --release \ + --dart-define-from-file=dart-defines/prod.json + @echo "AAB 路径:build/app/outputs/bundle/prodRelease/app-prod-release.aab" + +clean: + $(FLUTTER) clean + +gen: + dart run build_runner build --delete-conflicting-outputs + +gen-i18n: + dart run slang diff --git a/README.md b/README.md new file mode 100644 index 0000000..574b650 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# sunny_mochi + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..8cbe730 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + exclude: + - '**/*.g.dart' + - '**/*.freezed.dart' + - '**/*.gen.dart' + - 'lib/i18n/**' + errors: + invalid_annotation_target: ignore + +linter: + rules: + public_member_api_docs: false diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..bd9cb22 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,116 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +// 签名配置(android/key.properties 存在时启用;本地开发可不创建,CI/CD 必须提供) +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties().also { props -> + if (keystorePropertiesFile.exists()) { + props.load(keystorePropertiesFile.inputStream()) + } +} + +android { + namespace = "com.example.sunny_mochi" + // v1.2 锁定(详见 docs/flutter-architecture-design.md §十一.6): + // compileSdk = 36(编译期 API;插件生态已要求 36+) + // minSdk = 24(Android 7,OMA 安全推荐 + 主流国内 App 基线) + // targetSdk = 35(Google Play 2026 强制要求;运行时声明目标) + compileSdk = 36 + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + applicationId = "com.example.sunny_mochi" + minSdk = 24 + targetSdk = 35 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + // ── Flavor 三包共存(dev / staging / prod)──────────────────────────── + // 注意:Android Kotlin DSL 中 "test" 是保留字,测试环境 flavor 命名为 staging + flavorDimensions += "environment" + + productFlavors { + create("dev") { + dimension = "environment" + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev" + resValue("string", "app_name", "MyApp(开发版)") + } + create("staging") { + dimension = "environment" + applicationIdSuffix = ".staging" + versionNameSuffix = "-staging" + resValue("string", "app_name", "MyApp(测试版)") + } + create("prod") { + dimension = "environment" + // 正式包无后缀,applicationId 与 namespace 保持一致 + resValue("string", "app_name", "MyApp") + } + } + // ────────────────────────────────────────────────────────────────────── + + signingConfigs { + // release signingConfig:key.properties 存在时填充;否则 buildTypes.release 回退 debug + create("release") { + if (keystorePropertiesFile.exists()) { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + } + } + } + + buildTypes { + debug { + isMinifyEnabled = false + isDebuggable = true + } + release { + // dev / staging release 包:shrink 资源但不混淆(方便调试) + // prod release 包:全开 R8 混淆(配合 proguard-rules.pro) + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + // key.properties 存在时用 release 签名,否则回退 debug 签名(本地开发) + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + } + } + + // sqlcipher_flutter_libs 提供加密版 libsqlite3.so; + // tencent_cloud_chat_sdk 也打包了原版同名 .so,AGP 会报冲突并随机丢弃其中一份。 + // pickFirsts 保证遇到重名时优先保留(sqlcipher 插件在 classpath 中排前面)。 + packaging { + jniLibs { + pickFirsts += setOf("**/libsqlite3.so") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..48a8c65 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/sunny_mochi/MainActivity.kt b/android/app/src/main/kotlin/com/example/sunny_mochi/MainActivity.kt new file mode 100644 index 0000000..fa5d356 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/sunny_mochi/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.sunny_mochi + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/assets/fixtures/.gitkeep b/assets/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/fixtures/auth/login_success.json b/assets/fixtures/auth/login_success.json new file mode 100644 index 0000000..3bc697a --- /dev/null +++ b/assets/fixtures/auth/login_success.json @@ -0,0 +1,22 @@ +{ + "code": "00000", + "msg": "success", + "data": { + "saTokenInfo": { + "tokenName": "satoken", + "tokenValue": "mock-access-token-dev-1234567890abcdef", + "loginId": "1001", + "loginType": "login" + }, + "userInfo": { + "realName": "Mock User", + "avatar": "", + "phone": "13800138000", + "email": "mock@example.com", + "sex": 1, + "workNo": "EMP001", + "orgName": "Example Corp", + "deptName": "Engineering" + } + } +} diff --git a/assets/fixtures/auth/refresh_success.json b/assets/fixtures/auth/refresh_success.json new file mode 100644 index 0000000..1bc6ca2 --- /dev/null +++ b/assets/fixtures/auth/refresh_success.json @@ -0,0 +1,8 @@ +{ + "code": "00000", + "msg": "success", + "data": { + "access_token": "mock-refreshed-access-token-abcdef", + "refresh_token": "mock-refreshed-refresh-token-fedcba" + } +} diff --git a/assets/fixtures/auth/send_sms_code_success.json b/assets/fixtures/auth/send_sms_code_success.json new file mode 100644 index 0000000..3a7b3ae --- /dev/null +++ b/assets/fixtures/auth/send_sms_code_success.json @@ -0,0 +1,5 @@ +{ + "code": "00000", + "msg": "success", + "data": "123456" +} diff --git a/assets/fixtures/common/generic_success.json b/assets/fixtures/common/generic_success.json new file mode 100644 index 0000000..e0d83fe --- /dev/null +++ b/assets/fixtures/common/generic_success.json @@ -0,0 +1,5 @@ +{ + "code": "00000", + "msg": "success", + "data": null +} diff --git a/assets/fixtures/mine/avatar_upload.json b/assets/fixtures/mine/avatar_upload.json new file mode 100644 index 0000000..b6b0acb --- /dev/null +++ b/assets/fixtures/mine/avatar_upload.json @@ -0,0 +1,7 @@ +{ + "code": "00000", + "msg": "success", + "data": { + "url": "https://example.com/mock-avatar.jpg" + } +} diff --git a/assets/fixtures/mine/profile.json b/assets/fixtures/mine/profile.json new file mode 100644 index 0000000..042a7b5 --- /dev/null +++ b/assets/fixtures/mine/profile.json @@ -0,0 +1,15 @@ +{ + "code": "00000", + "msg": "success", + "data": { + "id": "1001", + "realName": "Mock User", + "avatar": "", + "phone": "13800138000", + "email": "mock@example.com", + "sex": 1, + "workNo": "EMP001", + "orgName": "Example Corp", + "deptName": "Engineering" + } +} diff --git a/assets/fonts/HarmonyOS_Sans_SC_Black.ttf b/assets/fonts/HarmonyOS_Sans_SC_Black.ttf new file mode 100644 index 0000000..261e92f Binary files /dev/null and b/assets/fonts/HarmonyOS_Sans_SC_Black.ttf differ diff --git a/assets/fonts/HarmonyOS_Sans_SC_Bold.ttf b/assets/fonts/HarmonyOS_Sans_SC_Bold.ttf new file mode 100644 index 0000000..04bd604 Binary files /dev/null and b/assets/fonts/HarmonyOS_Sans_SC_Bold.ttf differ diff --git a/assets/fonts/HarmonyOS_Sans_SC_Medium.ttf b/assets/fonts/HarmonyOS_Sans_SC_Medium.ttf new file mode 100644 index 0000000..5300289 Binary files /dev/null and b/assets/fonts/HarmonyOS_Sans_SC_Medium.ttf differ diff --git a/assets/fonts/HarmonyOS_Sans_SC_Regular.ttf b/assets/fonts/HarmonyOS_Sans_SC_Regular.ttf new file mode 100644 index 0000000..420ca80 Binary files /dev/null and b/assets/fonts/HarmonyOS_Sans_SC_Regular.ttf differ diff --git a/assets/icons/.gitkeep b/assets/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/icons/tabs/.gitkeep b/assets/icons/tabs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/images/.gitkeep b/assets/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/themes/.gitkeep b/assets/themes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dart-defines/dev.json b/dart-defines/dev.json new file mode 100644 index 0000000..a868c9e --- /dev/null +++ b/dart-defines/dev.json @@ -0,0 +1,8 @@ +{ + "ENV": "dev", + "USE_MOCK": "false", + "INTERNAL_BUILD": "true", + "SENTRY_DSN": "", + "PINNED_FINGERPRINTS": "", + "RSA_PUBLIC_KEY": "REPLACE_WITH_RSA_PUBLIC_KEY" +} diff --git a/dart-defines/prod.json.template b/dart-defines/prod.json.template new file mode 100644 index 0000000..34d2558 --- /dev/null +++ b/dart-defines/prod.json.template @@ -0,0 +1,8 @@ +{ + "ENV": "release", + "USE_MOCK": "false", + "INTERNAL_BUILD": "false", + "SENTRY_DSN": "REPLACE_WITH_ACTUAL_SENTRY_DSN", + "PINNED_FINGERPRINTS": "REPLACE_WITH_ACTUAL_FINGERPRINTS_AA:BB:CC", + "RSA_PUBLIC_KEY": "REPLACE_WITH_RSA_PUBLIC_KEY" +} diff --git a/dart-defines/staging.json b/dart-defines/staging.json new file mode 100644 index 0000000..11e78a4 --- /dev/null +++ b/dart-defines/staging.json @@ -0,0 +1,8 @@ +{ + "ENV": "test", + "USE_MOCK": "false", + "INTERNAL_BUILD": "true", + "SENTRY_DSN": "", + "PINNED_FINGERPRINTS": "", + "RSA_PUBLIC_KEY": "REPLACE_WITH_RSA_PUBLIC_KEY" +} diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..723ae96 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,5 @@ +environment: + sdk: ">=3.7.0 <4.0.0" + +flutter_test: + timeout: 60s diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/flavors/dev.xcconfig b/ios/Flutter/flavors/dev.xcconfig new file mode 100644 index 0000000..b1ae770 --- /dev/null +++ b/ios/Flutter/flavors/dev.xcconfig @@ -0,0 +1,4 @@ +#include "../Generated.xcconfig" +APP_DISPLAY_NAME = MyApp(开发版) +PRODUCT_BUNDLE_IDENTIFIER = com.example.sunnyMochi.dev +FLUTTER_FLAVOR = dev diff --git a/ios/Flutter/flavors/prod.xcconfig b/ios/Flutter/flavors/prod.xcconfig new file mode 100644 index 0000000..cfee739 --- /dev/null +++ b/ios/Flutter/flavors/prod.xcconfig @@ -0,0 +1,4 @@ +#include "../Generated.xcconfig" +APP_DISPLAY_NAME = MyApp +PRODUCT_BUNDLE_IDENTIFIER = com.example.sunnyMochi +FLUTTER_FLAVOR = prod diff --git a/ios/Flutter/flavors/staging.xcconfig b/ios/Flutter/flavors/staging.xcconfig new file mode 100644 index 0000000..c069bde --- /dev/null +++ b/ios/Flutter/flavors/staging.xcconfig @@ -0,0 +1,4 @@ +#include "../Generated.xcconfig" +APP_DISPLAY_NAME = MyApp(测试版) +PRODUCT_BUNDLE_IDENTIFIER = com.example.sunnyMochi.staging +FLUTTER_FLAVOR = staging diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..b75f79c --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,50 @@ +# Flutter Template — iOS Podfile +# 目标平台 iOS 15.0(v1.2 锁定,详见 docs/flutter-architecture-design.md §十一.6) +# Q1 答复 = 接受 iOS 15 → 保持本配置;如需保 iOS 13/14,调整 platform 行后重跑 pod install。 +platform :ios, '15.0' + +# CocoaPods 静态链接(与腾讯 IM 三件套兼容;P4 接入 IM 时再启用) +# 注意:use_frameworks! :linkage => :static 要求所有 Pod 支持静态库 +# ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + # 强制所有 Pod 的最低部署目标 ≥ iOS 15.0(与 Podfile platform 行对齐) + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + end + end +end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2c04004 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,620 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.sunnyMochi; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.sunnyMochi.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.sunnyMochi.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.sunnyMochi.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.sunnyMochi; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.sunnyMochi; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "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" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..133832f --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Sunny Mochi + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + sunny_mochi + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..fba7f77 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sunny_mochi/core/crash/crash_gate.dart'; +import 'package:sunny_mochi/core/router/app_router.dart'; +import 'package:sunny_mochi/core/theme/app_theme.dart'; +import 'package:sunny_mochi/core/theme/theme_notifier.dart'; + +class App extends ConsumerWidget { + const App({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouterProvider); + final themeMode = ref.watch(themeModeProvider); + final scheme = ref.watch(themeProvider); + + return MaterialApp.router( + title: 'Sunny Mochi', // TODO: 替换为项目应用名 + debugShowCheckedModeBanner: false, + theme: AppTheme.light(scheme: scheme), + darkTheme: AppTheme.dark(scheme: scheme), + themeMode: themeMode, + routerConfig: router, + // CrashGate 在 MaterialApp 内部:showDialog 需要 MaterialLocalizations + builder: (context, child) => CrashGate(child: child ?? const SizedBox()), + ); + } +} diff --git a/lib/core/config/api_config.dart b/lib/core/config/api_config.dart new file mode 100644 index 0000000..32c245c --- /dev/null +++ b/lib/core/config/api_config.dart @@ -0,0 +1,43 @@ +import 'package:sunny_mochi/core/config/env.dart'; + +/// API 网络配置 — **baseUrl 决策的单一权威** +/// +/// **对应 iOS BasicModule/Configuration/NetworkConfig.swift(2026-05 同步)**: +/// +/// | 环境 | iOS apiBaseURL | Flutter ApiConfig.baseUrl | +/// |------|---------------|--------------------------| +/// | dev | http://192.168.1.201:24801 | 同 | +/// | test | https://dev.yixiong-tech.com:8081 | 同 | +/// | release | https://bac.new.hamkke.top | 同 | +/// +/// **优先级**: +/// 1. CI/CD / 临时调试通过 `--dart-define=API_BASE_URL=...` 注入 → 优先 +/// 2. 否则按 `Env.name`(dev/test/release)返回 iOS 同源 URL +/// +/// **不再引入 h5BaseURL** — H5 评估报告已全部 Flutter 原生化(详见 +/// docs/h5-to-native-decision-2026-05-10.md),不再需要 in-app WebView。 +/// +/// **响应码请用 [ResponseCode]**(lib/core/network/response_code.dart)。 +/// **API 路径请用 [ApiPaths]**(lib/core/config/api_paths.dart)— +/// 不允许 datasource 散落字面量。 +abstract class ApiConfig { + /// API 网关 baseUrl — 与 iOS NetworkConfig.swift 同源 + static String get baseUrl { + // 1. dart-define 覆盖优先(CI/CD / 临时切换私有环境) + if (Env.apiBaseUrlOverride.isNotEmpty) { + return Env.apiBaseUrlOverride; + } + // 2. 按 Env.name 返回 iOS 同源 URL + return switch (Env.name) { + 'dev' => 'http://192.168.1.201:24801', + 'test' => 'https://dev.yixiong-tech.com:8081', + 'release' => 'https://bac.new.hamkke.top', + _ => 'http://192.168.1.201:24801', // 默认 dev(与 iOS Debug 包一致) + }; + } + + // 网络超时 — 对应共性需求说明 §移动端 7.网络异常处理(10s 超时建议) + static const Duration connectTimeout = Duration(seconds: 15); + static const Duration receiveTimeout = Duration(seconds: 30); + static const Duration sendTimeout = Duration(seconds: 30); +} diff --git a/lib/core/config/api_paths.dart b/lib/core/config/api_paths.dart new file mode 100644 index 0000000..d39da55 --- /dev/null +++ b/lib/core/config/api_paths.dart @@ -0,0 +1,72 @@ +/// API 路径常量(按微服务子系统分组)。 +/// +/// **零容忍规则**:所有 datasource 必须引用本文件常量;不允许字面量散落。 +/// +/// TODO: 填入项目 API 路径 +abstract class ApiPaths { + // ============== Auth ============== + // TODO: 填入项目 API 路径 + static const String authLoginSms = ''; + static const String authLoginPwd = ''; + + // TODO: 填入项目 API 路径 + static const String authSmsSend = ''; + + // TODO: 填入项目 API 路径 + static const String authLogout = ''; + // TODO: 填入项目 API 路径 + static const String authRefresh = ''; + + // ============== User / Mine ============== + // TODO: 填入项目 API 路径 + static const String userProfile = ''; + + // TODO: 填入项目 API 路径 + static const String userChangePassword = ''; + + // TODO: 填入项目 API 路径 + static const String userIdCardVerify = ''; + + // TODO: 填入项目 API 路径 + static const String userPhoneList = ''; + + // TODO: 填入项目 API 路径 + static const String userPhoneAdd = ''; + + // TODO: 填入项目 API 路径 + static const String userPhoneDelete = ''; + + // TODO: 填入项目 API 路径 + static const String userPhoneVerifyCode = ''; + + // TODO: 填入项目 API 路径 + static const String userPhoneSetDefault = ''; + + // TODO: 填入项目 API 路径 + static const String userAvatarUpload = ''; + + // TODO: 填入项目 API 路径 + static const String userAvatarSetUrl = ''; + + // TODO: 填入项目 API 路径 + static const String userPasswordVerify = ''; + + // TODO: 填入项目 API 路径 + static const String userPasswordReset = ''; + + // ============== Home ============== + // TODO: 填入项目 API 路径 + static const String homeBanner = ''; + + // ============== File ============== + // TODO: 填入项目 API 路径 + static const String fileUpload = ''; + + // ============== Error Report ============== + // TODO: 填入项目 API 路径 + static const String errorReport = ''; + + // ============== Crash Report ============== + // TODO: 填入项目 API 路径 + static const String crashReport = ''; +} diff --git a/lib/core/config/env.dart b/lib/core/config/env.dart new file mode 100644 index 0000000..6ef4df0 --- /dev/null +++ b/lib/core/config/env.dart @@ -0,0 +1,109 @@ +import 'package:flutter/foundation.dart'; +import 'package:sentry_flutter/sentry_flutter.dart' show SentryFlutter; + +/// 全局编译期环境配置。所有值通过 --dart-define 注入,避免明文 secrets。 +/// +/// **环境名与 iOS NetworkConfig.swift 对齐**:dev / test / release +/// +/// 用法: +/// ```bash +/// # 开发环境 + mock(默认) +/// flutter run --dart-define=ENV=dev --dart-define=USE_MOCK=true +/// +/// # 测试环境(连真服 dev.yixiong-tech.com:8081) +/// flutter run --dart-define=ENV=test +/// +/// # 正式环境(连 bac.new.hamkke.top) +/// flutter build apk --release --dart-define=ENV=release \ +/// --dart-define=SENTRY_DSN=https://...@sentry/1 +/// +/// # CI/CD 临时覆盖 API URL(不修改源码) +/// flutter run --dart-define=API_BASE_URL=https://my-private.example.com +/// ``` +/// +/// 详见: +/// - `docs/flutter-architecture-design.md` §九.1 / §十一.5 +/// - `docs/real-environment-verification.md`(环境就绪后填值) +/// - `lib/core/config/api_config.dart`(baseUrl 决策权威) +abstract class Env { + /// 当前环境名:**dev / test / release**(与 iOS NetworkConfig 三态对齐)。默认 dev。 + /// + /// - dev:开发环境(局域网 192.168.1.201:24801 / mock 模式可用) + /// - test:测试环境(dev.yixiong-tech.com:8081 — 与 iOS test 同源) + /// - release:正式环境(bac.new.hamkke.top — Release 包应锁定此值) + static const String name = String.fromEnvironment('ENV', defaultValue: 'dev'); + + /// API baseUrl 临时覆盖(CI/CD / 私有环境调试用)。 + /// + /// **正常情况下不应使用此变量** — 让 [ApiConfig.baseUrl] 按 [name] 自动选择 + /// iOS 同源 URL。仅当需要连私有/临时环境时通过 dart-define 注入。 + /// + /// ```bash + /// flutter run --dart-define=API_BASE_URL=https://my-private.example.com + /// ``` + static const String apiBaseUrlOverride = String.fromEnvironment( + 'API_BASE_URL', + ); + + /// Sentry DSN。**Q9 待答前**为空,[SentryFlutter.init] 自动跳过实际上报。 + static const String sentryDsn = String.fromEnvironment( + 'SENTRY_DSN', + ); + + /// 是否为内部测试包(决定 TalkerScreen 调试面板是否挂载)。 + /// 详见 docs/real-environment-verification.md §M4 / §Talker 准入。 + static const bool isInternalBuild = bool.fromEnvironment( + 'INTERNAL_BUILD', + ); + + /// 是否启用 mock fixtures 路径(绕过真实网络请求)。 + /// 详见 plan §Mock 与真实环境验证分层策略。 + static const bool useMock = bool.fromEnvironment( + 'USE_MOCK', + ); + + /// 腾讯云 IM SDKAppID(数字 ID)。 + /// **Q3 待答前**为 0(无效值,init 会返回失败但不崩溃)。 + /// 沙箱测试用控制台测试 SDKAppID;生产用真实业务 SDKAppID。 + /// 注入:--dart-define=IM_SDK_APP_ID=1400xxxxxx + static const int imSdkAppId = int.fromEnvironment( + 'IM_SDK_APP_ID', + ); + + /// RSA 公钥(DER-SPKI Base64)— 用于登录密码加密。 + /// 默认值 = iOS dev 公钥(对应 platform-ios/.../RSAEncryption.swift 中的 publicKeyString)。 + /// 生产 / Q6 答复后通过 --dart-define=RSA_PUBLIC_KEY=... 注入真实公钥。 + static const String rsaPublicKey = String.fromEnvironment( + 'RSA_PUBLIC_KEY', + defaultValue: + 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmZfR/bA9X3vp86y1aEpvwzXJYKRRF1fLau2+05/ZtaITLpV8bhkmSf3neSy/Q9gAdvG75Fr73E+GWE+K5b0BpvIS1jDGo319+PpZR39SaZTKZ27XFXrosmJTZutN79t819HS1VseleunHAFgMVufE9U5jP6LGzl/wbkSy01GhzwIDAQAB', + ); + + /// TLS 证书绑定指纹列表(SHA-256,多指纹支持轮换)。 + /// **Q2 待答前**为空,CertificatePinningInterceptor 在空列表时跳过校验。 + /// 真实指纹通过 --dart-define=PINNED_FINGERPRINTS=AA:BB,CC:DD 注入。 + static List get pinnedFingerprints { + const raw = String.fromEnvironment( + 'PINNED_FINGERPRINTS', + ); + if (raw.isEmpty) return const []; + return raw + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + + // ---- 便捷判断(对齐 iOS NetworkConfig.Environment 三态)---- + static bool get isDev => name == 'dev'; + static bool get isTest => name == 'test'; + static bool get isRelease => name == 'release'; + + /// 兼容旧调用 — 历史代码可能用 isProd 判断 + /// @Deprecated 新代码请用 [isRelease] + static bool get isProd => isRelease; + + /// Release 包除非 INTERNAL_BUILD=true,否则视为生产模式。 + /// 用于 TalkerScreen / Riverpod observer 等开发面板的门控。 + static bool get enableDevPanel => kDebugMode || isInternalBuild; +} diff --git a/lib/core/crash/crash_datasource.dart b/lib/core/crash/crash_datasource.dart new file mode 100644 index 0000000..44ec475 --- /dev/null +++ b/lib/core/crash/crash_datasource.dart @@ -0,0 +1,35 @@ +import 'package:dio/dio.dart'; +import 'package:sunny_mochi/core/config/api_paths.dart'; +import 'package:sunny_mochi/core/crash/pending_crash_report.dart'; +import 'package:sunny_mochi/core/network/api_response.dart'; +import 'package:sunny_mochi/core/network/dio_client.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'crash_datasource.g.dart'; + +@riverpod +CrashDatasource crashDatasource(Ref ref) => + CrashDatasource(ref.watch(dioClientProvider)); + +/// 崩溃报告上报接口。 +/// +/// **后端接口规范(待后端实现)**: +/// ``` +/// POST /sys/crash-report +/// Headers: satoken: (可选,未登录也应接受) +/// Body: { "kind", "error", "stackTrace", "occurredAt", "deviceModel", ... } +/// Response: { "retCode": "00000", "retMsg": "ok", "retData": null } +/// ``` +// TODO(backend): 实现 POST /sys/crash-report 接口(接收客户端崩溃日志) +class CrashDatasource { + const CrashDatasource(this._dio); + final Dio _dio; + + Future sendCrashReport(PendingCrashReport report) async { + final resp = await _dio.post>( + ApiPaths.crashReport, + data: report.toJson(), + ); + parseEnvelope(resp.data, (j) => j).unwrapVoid(); + } +} diff --git a/lib/core/crash/crash_datasource.g.dart b/lib/core/crash/crash_datasource.g.dart new file mode 100644 index 0000000..9551fcd --- /dev/null +++ b/lib/core/crash/crash_datasource.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'crash_datasource.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(crashDatasource) +final crashDatasourceProvider = CrashDatasourceProvider._(); + +final class CrashDatasourceProvider + extends + $FunctionalProvider + with $Provider { + CrashDatasourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'crashDatasourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$crashDatasourceHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + CrashDatasource create(Ref ref) { + return crashDatasource(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CrashDatasource value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$crashDatasourceHash() => r'4506810eb85819b314f44127ee944feb65b56b20'; diff --git a/lib/core/crash/crash_dialog.dart b/lib/core/crash/crash_dialog.dart new file mode 100644 index 0000000..7d4c8c0 --- /dev/null +++ b/lib/core/crash/crash_dialog.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:sunny_mochi/core/crash/crash_datasource.dart'; +import 'package:sunny_mochi/core/crash/pending_crash_report.dart'; +import 'package:sunny_mochi/core/extensions/context_x.dart'; +import 'package:sunny_mochi/core/widgets/app_toast.dart'; + +/// 崩溃报告对话框 — 下次启动时强制展示。 +/// +/// 用户体验: +/// - "发送报告":POST 上报 → 关闭弹窗,后台继续正常启动 +/// - "跳过":直接关闭弹窗,继续正常启动 +class CrashDialog extends ConsumerStatefulWidget { + const CrashDialog({super.key, required this.report}); + final PendingCrashReport report; + + @override + ConsumerState createState() => _CrashDialogState(); +} + +class _CrashDialogState extends ConsumerState { + bool _sending = false; + bool _showDetail = false; + + Future _send() async { + setState(() => _sending = true); + try { + await ref + .read(crashDatasourceProvider) + .sendCrashReport(widget.report); + if (mounted) { + Navigator.of(context).pop(); + context.showToast('崩溃报告已发送,感谢您的反馈', type: ToastType.success); + } + } catch (e, st) { + if (mounted) { + setState(() => _sending = false); + context.showError(ref, e, st); + } + } + } + + @override + Widget build(BuildContext context) { + final report = widget.report; + final time = report.occurredAtLocal; + final timeStr = time == null + ? '未知时间' + : '${time.year}-${time.month.toString().padLeft(2, '0')}-' + '${time.day.toString().padLeft(2, '0')} ' + '${time.hour.toString().padLeft(2, '0')}:' + '${time.minute.toString().padLeft(2, '0')}'; + + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.r), + ), + title: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: const Color(0xFFFF9F0A), + size: 22.sp, + ), + SizedBox(width: 8.w), + Text( + '上次发生了意外退出', + style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '应用在 $timeStr 崩溃退出。发送崩溃报告有助于我们修复问题,报告不包含您的个人数据。', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF636366), + height: 1.5, + ), + ), + SizedBox(height: 12.h), + _InfoRow('设备', '${report.deviceModel} / ${report.osVersion}'), + _InfoRow('版本', '${report.appVersion}+${report.appBuild}'), + SizedBox(height: 8.h), + GestureDetector( + onTap: () => setState(() => _showDetail = !_showDetail), + child: Row( + children: [ + Text( + '错误详情', + style: TextStyle( + fontSize: 13.sp, + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + Icon( + _showDetail + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 18.sp, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + if (_showDetail) ...[ + SizedBox(height: 8.h), + Container( + constraints: BoxConstraints(maxHeight: 160.h), + decoration: BoxDecoration( + color: const Color(0xFFF2F2F7), + borderRadius: BorderRadius.circular(8.r), + ), + child: SingleChildScrollView( + padding: + EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h), + child: Text( + '${report.error}\n\n${_shortStack(report.stackTrace)}', + style: TextStyle( + fontSize: 11.sp, + color: const Color(0xFF3A3A3C), + fontFamily: 'monospace', + height: 1.4, + ), + ), + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: _sending ? null : () => Navigator.of(context).pop(), + child: Text( + '跳过', + style: TextStyle( + color: const Color(0xFF8E8E93), + fontSize: 15.sp, + ), + ), + ), + FilledButton( + onPressed: _sending ? null : _send, + child: _sending + ? SizedBox( + width: 16.w, + height: 16.w, + child: const CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text('发送报告', style: TextStyle(fontSize: 15.sp)), + ), + ], + ); + } + + String _shortStack(String stack) { + final lines = stack.split('\n'); + if (lines.length <= 20) return stack; + return '${lines.take(20).join('\n')}\n...(${lines.length - 20} more lines)'; + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow(this.label, this.value); + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: 4.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 36.w, + child: Text( + label, + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFFAEAEB2), + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF636366), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/core/crash/crash_gate.dart b/lib/core/crash/crash_gate.dart new file mode 100644 index 0000000..86dbb40 --- /dev/null +++ b/lib/core/crash/crash_gate.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sunny_mochi/core/crash/crash_dialog.dart'; +import 'package:sunny_mochi/core/crash/crash_reporter.dart'; + +/// 应用根部崩溃检测门(wraps AppRoot)。 +/// +/// 在第一帧渲染完成后检查 [pendingCrashProvider],若非 null 则强制弹出 +/// [CrashDialog](barrierDismissible: false)。 +class CrashGate extends ConsumerStatefulWidget { + const CrashGate({super.key, required this.child}); + final Widget child; + + @override + ConsumerState createState() => _CrashGateState(); +} + +class _CrashGateState extends ConsumerState { + @override + void initState() { + super.initState(); + final report = ref.read(pendingCrashProvider); + if (report != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => CrashDialog(report: report), + ), + ); + } + }); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/core/crash/crash_reporter.dart b/lib/core/crash/crash_reporter.dart new file mode 100644 index 0000000..4e5dc58 --- /dev/null +++ b/lib/core/crash/crash_reporter.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sunny_mochi/core/crash/pending_crash_report.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'crash_reporter.g.dart'; + +/// Riverpod provider:崩溃报告在 main.dart 通过 overrideWithValue 注入。 +/// 默认值为 null(正常启动时无崩溃)。 +@Riverpod(keepAlive: true) +PendingCrashReport? pendingCrash(Ref ref) => null; + +/// 全局崩溃日志服务(纯静态,不依赖 Riverpod / Flutter framework)。 +/// +/// **生命周期**: +/// 1. `preInit()` — main() 最早期调用,异步缓存路径 + 设备信息 +/// 2. `consumePending()` — 读取并删除上次崩溃文件(一次性消费) +/// 3. `installHooks()` — 在 SentrySetup.init appRunner 内调用,链式挂载钩子 +/// +/// **崩溃写入**:仅用 `File.writeAsStringSync(flush: true)`,绝对不能 await。 +abstract class CrashReporter { + static String? _crashFilePath; + static String _deviceModel = 'unknown'; + static String _osVersion = 'unknown'; + static String _appVersion = 'unknown'; + static String _appBuild = '0'; + + /// 步骤 1:异步预初始化(收集路径 + 设备信息)。 + static Future preInit() async { + try { + final dir = await getApplicationDocumentsDirectory(); + _crashFilePath = p.join(dir.path, 'crash_report.json'); + + final pkg = await PackageInfo.fromPlatform(); + _appVersion = pkg.version; + _appBuild = pkg.buildNumber; + + if (kIsWeb) return; + + if (Platform.isAndroid) { + final info = await DeviceInfoPlugin().androidInfo; + _deviceModel = '${info.manufacturer} ${info.model}'.trim(); + _osVersion = 'Android ${info.version.release}'; + } else if (Platform.isIOS) { + final info = await DeviceInfoPlugin().iosInfo; + _deviceModel = info.utsname.machine; + _osVersion = '${info.systemName} ${info.systemVersion}'; + } else { + _deviceModel = Platform.operatingSystem; + _osVersion = Platform.operatingSystemVersion; + } + } catch (_) { + // preInit 失败不能阻断启动流程 + } + } + + /// 步骤 2:一次性消费崩溃文件(读取 + 立即删除)。 + /// 返回 null 表示本次是正常启动。 + static PendingCrashReport? consumePending() { + final path = _crashFilePath; + if (path == null) return null; + + final file = File(path); + if (!file.existsSync()) return null; + + try { + final raw = file.readAsStringSync(); + file.deleteSync(); + return PendingCrashReport.fromRawJson(raw); + } catch (_) { + try { + file.deleteSync(); + } catch (_) {} + return null; + } + } + + /// 步骤 3:安装全局崩溃钩子(必须在 SentrySetup appRunner 内调用)。 + /// + /// 采用链式挂载:先读出 Sentry 已设置的 handler,我们包一层写回, + /// 确保 Sentry 依然能接到所有错误。 + static void installHooks() { + final previousOnError = PlatformDispatcher.instance.onError; + PlatformDispatcher.instance.onError = (error, stack) { + _writeCrashSync(kind: 'dart', error: error, stackTrace: stack); + return previousOnError?.call(error, stack) ?? false; + }; + } + + /// 同步写入崩溃文件(crash hook 专用,不能有任何 await)。 + static void _writeCrashSync({ + required String kind, + required Object error, + required StackTrace stackTrace, + }) { + final path = _crashFilePath; + if (path == null) return; + + try { + final stackStr = stackTrace.toString(); + final truncated = stackStr.length > 4096 + ? '${stackStr.substring(0, 4096)}\n...(truncated)' + : stackStr; + + final report = PendingCrashReport( + kind: kind, + error: error.toString(), + stackTrace: truncated, + occurredAt: DateTime.now().toUtc().toIso8601String(), + deviceModel: _deviceModel, + osVersion: _osVersion, + appVersion: _appVersion, + appBuild: _appBuild, + ); + + File(path).writeAsStringSync( + report.toRawJson(), + flush: true, + ); + } catch (_) { + // crash hook 内绝对不能抛出异常 + } + } +} diff --git a/lib/core/crash/crash_reporter.g.dart b/lib/core/crash/crash_reporter.g.dart new file mode 100644 index 0000000..302a3d5 --- /dev/null +++ b/lib/core/crash/crash_reporter.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'crash_reporter.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Riverpod provider:崩溃报告在 main.dart 通过 overrideWithValue 注入。 +/// 默认值为 null(正常启动时无崩溃)。 + +@ProviderFor(pendingCrash) +final pendingCrashProvider = PendingCrashProvider._(); + +/// Riverpod provider:崩溃报告在 main.dart 通过 overrideWithValue 注入。 +/// 默认值为 null(正常启动时无崩溃)。 + +final class PendingCrashProvider + extends + $FunctionalProvider< + PendingCrashReport?, + PendingCrashReport?, + PendingCrashReport? + > + with $Provider { + /// Riverpod provider:崩溃报告在 main.dart 通过 overrideWithValue 注入。 + /// 默认值为 null(正常启动时无崩溃)。 + PendingCrashProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'pendingCrashProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$pendingCrashHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + PendingCrashReport? create(Ref ref) { + return pendingCrash(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PendingCrashReport? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$pendingCrashHash() => r'4ccef6df51aa7ed093a0ac08518772bbd70ecc4e'; diff --git a/lib/core/crash/pending_crash_report.dart b/lib/core/crash/pending_crash_report.dart new file mode 100644 index 0000000..c75546f --- /dev/null +++ b/lib/core/crash/pending_crash_report.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +/// 上次启动前崩溃的快照(从 crash_report.json 反序列化)。 +/// +/// 手写 fromJson/toJson — 文件写入发生在崩溃现场,不能依赖 code generator。 +class PendingCrashReport { + const PendingCrashReport({ + required this.kind, + required this.error, + required this.stackTrace, + required this.occurredAt, + required this.deviceModel, + required this.osVersion, + required this.appVersion, + required this.appBuild, + this.context, + }); + + /// 崩溃来源:dart(未捕获异常)/ zone(Zone 逃逸) + final String kind; + + /// 原始异常 toString(技术细节,不展示给用户) + final String error; + + /// 堆栈(已截断至 4096 字符) + final String stackTrace; + + /// UTC ISO 8601 + final String occurredAt; + + final String deviceModel; + final String osVersion; + final String appVersion; + final String appBuild; + + /// FlutterErrorDetails.context(仅 flutter 类型有值) + final String? context; + + factory PendingCrashReport.fromJson(Map json) => + PendingCrashReport( + kind: json['kind'] as String? ?? 'unknown', + error: json['error'] as String? ?? '', + stackTrace: json['stackTrace'] as String? ?? '', + occurredAt: json['occurredAt'] as String? ?? '', + deviceModel: json['deviceModel'] as String? ?? 'unknown', + osVersion: json['osVersion'] as String? ?? 'unknown', + appVersion: json['appVersion'] as String? ?? 'unknown', + appBuild: json['appBuild'] as String? ?? '0', + context: json['context'] as String?, + ); + + factory PendingCrashReport.fromRawJson(String raw) => + PendingCrashReport.fromJson(jsonDecode(raw) as Map); + + Map toJson() => { + 'kind': kind, + 'error': error, + 'stackTrace': stackTrace, + 'occurredAt': occurredAt, + 'deviceModel': deviceModel, + 'osVersion': osVersion, + 'appVersion': appVersion, + 'appBuild': appBuild, + if (context != null) 'context': context, + }; + + String toRawJson() => jsonEncode(toJson()); + + /// 崩溃时间(本地时区,用于 UI 展示)。 + DateTime? get occurredAtLocal { + try { + return DateTime.parse(occurredAt).toLocal(); + } catch (_) { + return null; + } + } +} diff --git a/lib/core/crypto/rsa_helper.dart b/lib/core/crypto/rsa_helper.dart new file mode 100644 index 0000000..705cf3e --- /dev/null +++ b/lib/core/crypto/rsa_helper.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:asn1lib/asn1lib.dart'; +import 'package:pointycastle/api.dart'; +import 'package:pointycastle/asymmetric/api.dart'; +import 'package:pointycastle/asymmetric/pkcs1.dart'; +import 'package:pointycastle/asymmetric/rsa.dart'; + +/// RSA 公钥加密(对应 iOS BasicModule/Util/RSAEncryption.swift)。 +/// +/// - 算法:RSA / PKCS#1 v1.5 padding(与 iOS `SecKeyCreateEncryptedData` + +/// `.rsaEncryptionPKCS1` 完全对齐) +/// - 公钥格式:DER-SPKI Base64(X.509 SubjectPublicKeyInfo) +/// - 输出:标准 Base64(与服务端 `Base64.getDecoder()` 兼容) +/// +/// 用法: +/// ```dart +/// final cipher = RsaHelper.encrypt( +/// 'myPassword', +/// publicKeyDerBase64: Env.rsaPublicKey, +/// ); +/// ``` +/// +/// 真实公钥 = `Env.rsaPublicKey`(--dart-define 注入;当前 Env 默认值 = iOS dev 公钥)。 +class RsaHelper { + RsaHelper._(); + + /// 用公钥加密明文,返回 Base64 密文。 + /// + /// **R-ROB-2 / 2026-05-10**:失败必须抛 [RsaEncryptionException],**不允许返回 null** — + /// 健康类 App 加密失败必须显式中断业务流,不允许调用方 fallback 到明文(合规底线)。 + /// 之前的 `String?` 签名依赖调用方人工 null 检查,新调用方易漏检导致请求送 "null" 串。 + static String encrypt( + String plaintext, { + required String publicKeyDerBase64, + }) { + // R-ROB-2 / 2026-05-10:拒绝空明文 — _processInBlocks 对 0 字节输入会返回空密文 + // (旧行为是 cipher='' 被 isNotNull 测试掩盖的 bug);业务侧密码也不应为空 + if (plaintext.isEmpty) { + throw RsaEncryptionException('明文为空,拒绝加密'); + } + + final RSAPublicKey pubKey; + try { + pubKey = _parseSpkiBase64(publicKeyDerBase64); + } on Object catch (e, st) { + throw RsaEncryptionException( + 'RSA 公钥解析失败:${e.runtimeType}', + cause: e, + stackTrace: st, + ); + } + + try { + final padding = PKCS1Encoding(RSAEngine()) + ..init(true, PublicKeyParameter(pubKey)); + + final input = Uint8List.fromList(utf8.encode(plaintext)); + final output = _processInBlocks(padding, input); + return base64.encode(output); + } on Object catch (e, st) { + throw RsaEncryptionException( + 'RSA 加密失败:${e.runtimeType}', + cause: e, + stackTrace: st, + ); + } + } + + /// 把 SPKI Base64 公钥(X.509 SubjectPublicKeyInfo)解析为 [RSAPublicKey]。 + /// + /// 失败抛错(不再 swallow)— 上层 [encrypt] 统一转换为 [RsaEncryptionException]。 + static RSAPublicKey _parseSpkiBase64(String spkiBase64) { + final bytes = base64.decode(spkiBase64.replaceAll(RegExp(r'\s+'), '')); + final asn1Parser = ASN1Parser(bytes); + final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence; + // SPKI: SEQUENCE { algorithm SEQUENCE { OID, NULL }, subjectPublicKey BIT STRING } + final publicKeyBitString = topLevelSeq.elements[1] as ASN1BitString; + final publicKeyAsn = ASN1Parser(publicKeyBitString.contentBytes()); + final publicKeySeq = publicKeyAsn.nextObject() as ASN1Sequence; + final modulus = publicKeySeq.elements[0] as ASN1Integer; + final exponent = publicKeySeq.elements[1] as ASN1Integer; + return RSAPublicKey( + modulus.valueAsBigInteger, + exponent.valueAsBigInteger, + ); + } + + static Uint8List _processInBlocks( + AsymmetricBlockCipher engine, + Uint8List input, + ) { + final numBlocks = (input.length / engine.inputBlockSize).ceil(); + final output = Uint8List(numBlocks * engine.outputBlockSize); + var inputOffset = 0; + var outputOffset = 0; + while (inputOffset < input.length) { + final size = (inputOffset + engine.inputBlockSize <= input.length) + ? engine.inputBlockSize + : input.length - inputOffset; + final processed = engine.process( + Uint8List.sublistView(input, inputOffset, inputOffset + size), + ); + output.setRange( + outputOffset, + outputOffset + processed.length, + processed, + ); + inputOffset += size; + outputOffset += processed.length; + } + return Uint8List.sublistView(output, 0, outputOffset); + } +} + +/// RSA 加密失败异常 — 调用方应 catch 并转为业务层 [Failure](如 AuthFailure(cryptoFailed)), +/// **绝不允许** 把明文继续上送服务端。 +class RsaEncryptionException implements Exception { + RsaEncryptionException(this.message, {this.cause, this.stackTrace}); + + final String message; + final Object? cause; + final StackTrace? stackTrace; + + @override + String toString() => 'RsaEncryptionException: $message'; +} diff --git a/lib/core/data/freshness_policy.dart b/lib/core/data/freshness_policy.dart new file mode 100644 index 0000000..23e88db --- /dev/null +++ b/lib/core/data/freshness_policy.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +/// Repository 层数据新鲜度策略 — 反馈文档 §"实时性分级的工程落地"。 +/// +/// **核心理念**:把"数据新鲜度"和"UI 可用性"解耦: +/// - 弱实时性场景(列表 / 档案)→ 优先返回缓存,后台刷新 +/// - 强实时性场景(订单状态 / 支付结果)→ 优先实时,失败才降级 +/// +/// 在 Repository 方法签名里把策略当参数传入: +/// ```dart +/// Future> fetchOrders({ +/// FreshnessPolicy policy = FreshnessPolicy.cacheFirst, +/// }); +/// ``` +/// UseCase / Notifier 决定使用哪种策略,Repository 内部用 [policyAwareFetch] +/// 实现统一的"读缓存 / 拉网络 / 写缓存"流程。 +enum FreshnessPolicy { + /// 弱实时:先缓存,后台刷新。 + /// 网络失败不影响 UI 显示(缓存兜底)。**列表页、档案展示首选**。 + cacheFirst, + + /// 强实时:先尝试网络,失败时才降级到缓存。 + /// 适合订单状态、库存、消息计数等"过期数据会误导用户"的场景。 + networkFirst, + + /// 纯离线:只读缓存,不发起网络请求。 + /// 适合电梯/地铁等已知无网场景,或用户主动选择离线模式。 + cacheOnly, + + /// 强制实时:只走网络,不读不写缓存。 + /// 适合一次性操作(提交表单、确认下单)—— 缓存反而是污染源。 + networkOnly, +} + +/// 按 [FreshnessPolicy] 编排"读缓存 + 拉网络 + 写缓存"的统一流程。 +/// +/// **接入约定**: +/// - [readCache]:从 Drift / Hive 读最近一份数据,**无缓存返回 null**(不要抛异常) +/// - [fetchRemote]:调 datasource 拉网络,失败抛 [Exception](被本函数 catch) +/// - [writeCache]:把网络结果写回缓存(cacheOnly / networkOnly 不会触发) +/// +/// **行为矩阵**: +/// +/// | 策略 | 读缓存 | 网络 | 网络失败 | +/// |------|------|------|---------| +/// | cacheFirst | 立即返回(如有)+ 后台刷新写回 | 后台 | 静默忽略(缓存已显示) | +/// | networkFirst | — | 前台 | 降级返回缓存(如有),无缓存则抛 | +/// | cacheOnly | 立即返回(如无返回 null) | 不发 | — | +/// | networkOnly | 不读 | 前台 | 抛 | +/// +/// 返回 [Stream] 而非 Future 的原因:cacheFirst 场景 UI 需要先看到缓存、 +/// 后台刷新后再看到最新值,自然是两次 emit。Drift 本身就支持 Stream, +/// 让 UI 通过 StreamProvider 订阅即可。 +Stream policyAwareFetch({ + required FreshnessPolicy policy, + required Future Function() readCache, + required Future Function() fetchRemote, + required Future Function(T value) writeCache, +}) async* { + switch (policy) { + case FreshnessPolicy.cacheOnly: + yield await readCache(); + + case FreshnessPolicy.networkOnly: + yield await fetchRemote(); + + case FreshnessPolicy.cacheFirst: + final cached = await readCache(); + if (cached != null) yield cached; + try { + final fresh = await fetchRemote(); + await writeCache(fresh); + yield fresh; + } on Object { + // 静默:UI 已显示缓存值,网络失败不打扰用户 + // 业务侧若需提示"刷新失败",应在 Notifier 层用 ref.listen 单独监听网络层 Failure + if (cached == null) rethrow; + } + + case FreshnessPolicy.networkFirst: + try { + final fresh = await fetchRemote(); + await writeCache(fresh); + yield fresh; + } on Object catch (_) { + final cached = await readCache(); + if (cached != null) { + yield cached; + } else { + rethrow; + } + } + } +} diff --git a/lib/core/error/exception_mapper.dart b/lib/core/error/exception_mapper.dart new file mode 100644 index 0000000..8a2bd6b --- /dev/null +++ b/lib/core/error/exception_mapper.dart @@ -0,0 +1,161 @@ +import 'package:dio/dio.dart'; +import 'package:sunny_mochi/core/crypto/rsa_helper.dart' show RsaEncryptionException; +import 'package:sunny_mochi/core/error/failures.dart'; +import 'package:sunny_mochi/core/network/response_code.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'exception_mapper.g.dart'; + +/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。 +@Riverpod(keepAlive: true) +ExceptionMapper exceptionMapper(Ref ref) => const ExceptionMapper(); + +/// 把底层异常(DioException / DriftException / 其他)映射为应用层 [Failure]。 +/// +/// - 网络/HTTP → NetworkFailure / ServerFailure / AuthFailure +/// - 业务 envelope retCode 非成功 → ServerFailure / AuthFailure +/// - 数据库异常 → CacheFailure +/// - 兜底 → UnknownFailure +/// +/// 详见 docs/flutter-architecture-design.md §四.2。 +class ExceptionMapper { + const ExceptionMapper(); + + Failure fromDio(DioException e) { + return switch (e.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => NetworkFailure( + message: '网络超时,请稍后重试', + kind: NetworkFailureKind.timeout, + cause: e, + stackTrace: e.stackTrace, + ), + DioExceptionType.connectionError => NetworkFailure( + message: '网络不可用,请检查连接', + kind: NetworkFailureKind.noNetwork, + cause: e, + stackTrace: e.stackTrace, + ), + DioExceptionType.badCertificate => NetworkFailure( + message: '证书校验失败,可能存在中间人风险', + kind: NetworkFailureKind.badCertificate, + cause: e, + stackTrace: e.stackTrace, + ), + DioExceptionType.cancel => NetworkFailure( + message: '请求已取消', + kind: NetworkFailureKind.cancelled, + cause: e, + stackTrace: e.stackTrace, + ), + DioExceptionType.badResponse => _fromHttpStatus(e), + DioExceptionType.unknown => _fromUnknownDio(e), + }; + } + + Failure _fromHttpStatus(DioException e) { + final status = e.response?.statusCode; + if (status == 401) { + // 三态分类(与 TokenRefreshInterceptor 协作 — 反馈文档 §"Token 注销逻辑的编排"): + // 1. _auth_refresh_failed → refresh 用尽,必须重新登录 + // 2. _auth_not_refreshable → 业务 401(如权限不足),不应跳登录 + // 3. 其他 → 真 token 过期但未触发 refresh(理论不应到这里),按 unauthorized 兜底 + final extra = e.requestOptions.extra; + if (extra['_auth_refresh_failed'] == true) { + return AuthFailure( + message: '登录状态已失效,请重新登录', + kind: AuthFailureKind.refreshFailed, + cause: e, + stackTrace: e.stackTrace, + ); + } + if (extra['_auth_not_refreshable'] == true) { + return AuthFailure( + message: _businessAuthMessage(e) ?? '当前账号无访问权限', + kind: AuthFailureKind.forbidden, + cause: e, + stackTrace: e.stackTrace, + ); + } + return AuthFailure( + message: '登录已过期,请重新登录', + cause: e, + stackTrace: e.stackTrace, + ); + } + if (status == 403) { + return AuthFailure( + message: '无访问权限', + kind: AuthFailureKind.forbidden, + cause: e, + stackTrace: e.stackTrace, + ); + } + // message 保留技术细节供 Sentry/Talker;userMessage 由 ServerFailure getter 遮蔽 + return ServerFailure( + message: e.message ?? 'HTTP $status', + statusCode: status, + cause: e, + stackTrace: e.stackTrace, + ); + } + + /// 从响应 body 中提取业务侧 message,给业务 401 一个更具体的提示。 + String? _businessAuthMessage(DioException e) { + final body = e.response?.data; + if (body is! Map) return null; + final raw = body['retMsg'] ?? body['msg'] ?? body['message']; + final msg = raw is String ? raw : raw?.toString(); + return (msg != null && msg.isNotEmpty) ? msg : null; + } + + Failure _fromUnknownDio(DioException e) { + final inner = e.error; + if (inner is Failure) return inner; + return UnknownFailure( + message: e.message ?? '未知错误', + cause: e, + stackTrace: e.stackTrace, + ); + } + + /// 把业务 envelope 错误码映射为 Failure。 + Failure fromBusinessCode(String? code, String? message) { + final friendly = ResponseCode.friendlyMessage(code, message ?? '请求失败'); + if (ResponseCode.isUnauthorized(code)) { + return AuthFailure(message: friendly); + } + return ServerFailure(message: friendly, code: code); + } + + /// Drift / 文件 IO 等本地异常 → CacheFailure。 + Failure fromCache(Object error, [StackTrace? st]) { + return CacheFailure( + message: '本地数据访问失败:$error', + cause: error, + stackTrace: st, + ); + } + + /// 兜底(任意未识别异常)。 + Failure fromUnknown(Object error, [StackTrace? st]) { + if (error is Failure) return error; + if (error is DioException) return fromDio(error); + // R-ROB-2 / 2026-05-10:RSA 加密失败统一映射为 AuthFailure(cryptoFailed), + // 避免上层得到不友好的 'RsaEncryptionException: ...' 兜底文案 + if (error is RsaEncryptionException) { + return AuthFailure( + message: '密码加密失败,请重试', + kind: AuthFailureKind.cryptoFailed, + cause: error, + stackTrace: st, + ); + } + return UnknownFailure( + message: error.toString(), + cause: error, + stackTrace: st, + ); + } +} diff --git a/lib/core/error/exception_mapper.g.dart b/lib/core/error/exception_mapper.g.dart new file mode 100644 index 0000000..c34f9d6 --- /dev/null +++ b/lib/core/error/exception_mapper.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'exception_mapper.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。 + +@ProviderFor(exceptionMapper) +final exceptionMapperProvider = ExceptionMapperProvider._(); + +/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。 + +final class ExceptionMapperProvider + extends + $FunctionalProvider + with $Provider { + /// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。 + ExceptionMapperProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'exceptionMapperProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$exceptionMapperHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + ExceptionMapper create(Ref ref) { + return exceptionMapper(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ExceptionMapper value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$exceptionMapperHash() => r'b02606666fb3e934dd54e4440c633471ab224c20'; diff --git a/lib/core/error/failures.dart b/lib/core/error/failures.dart new file mode 100644 index 0000000..5805d1c --- /dev/null +++ b/lib/core/error/failures.dart @@ -0,0 +1,134 @@ +import 'package:sunny_mochi/core/error/exception_mapper.dart' + show ExceptionMapper; + +/// 应用层统一失败模型。 +/// +/// 所有数据/网络/缓存层的异常通过 [ExceptionMapper] 转换为 [Failure] 子类, +/// presentation 层只面对 Failure,避免散落 try-catch DioException / DriftException。 +/// +/// **实现 [Exception]**:让 datasource/repository 可以直接 `throw failure;` +/// 不触发 `only_throw_errors` lint。 +/// +/// 详见 docs/flutter-architecture-design.md §四.2。 +sealed class Failure implements Exception { + const Failure({required this.message, this.cause, this.stackTrace}); + + final String message; + final Object? cause; + final StackTrace? stackTrace; + + /// 子类自我描述前缀(避免直接用 [Object.runtimeType],Release 混淆友好)。 + String get _typeName; + + /// 适合展示给用户的文案(区别于 [message],后者含技术细节,供 Sentry/Talker 日志使用)。 + String get userMessage; + + @override + String toString() => '$_typeName: $message'; +} + +/// 网络层失败:超时 / 无网 / DNS 解析失败 / 证书绑定失败 / 连接断开。 +class NetworkFailure extends Failure { + const NetworkFailure({ + required super.message, + this.kind = NetworkFailureKind.unknown, + super.cause, + super.stackTrace, + }); + + final NetworkFailureKind kind; + + @override + String get _typeName => 'NetworkFailure'; + + @override + String get userMessage => message; +} + +enum NetworkFailureKind { + timeout, + noNetwork, + badCertificate, + cancelled, + unknown, +} + +/// 鉴权失败:未登录 / Token 过期 / refresh 失败 → 应跳登录。 +class AuthFailure extends Failure { + const AuthFailure({ + required super.message, + this.kind = AuthFailureKind.unauthorized, + super.cause, + super.stackTrace, + }); + + final AuthFailureKind kind; + + @override + String get _typeName => 'AuthFailure'; + + @override + String get userMessage => message; +} + +enum AuthFailureKind { + unauthorized, // A0401 + forbidden, // 403 + refreshFailed, + cryptoFailed, // 客户端加密失败(如登录密码 RSA 加密前置错) +} + +/// 服务端业务失败:500/501 / 业务校验失败 / 参数错误。 +class ServerFailure extends Failure { + const ServerFailure({ + required super.message, + this.code, + this.statusCode, + super.cause, + super.stackTrace, + }); + + final String? code; // 业务错误码(如 A0500 / A0400 / A0404) + final int? statusCode; // HTTP 状态码 + + @override + String get _typeName => 'ServerFailure'; + + @override + String get userMessage { + // HTTP 5xx:服务端内部错误,后端 message 可能含技术细节(SQL/堆栈),统一遮蔽 + if (statusCode != null && statusCode! >= 500) return '服务器开小差了,请稍后再试'; + // 业务码 / 4xx:message 已经过 ResponseCode.friendlyMessage 映射,安全展示 + return message; + } +} + +/// 本地缓存/数据库失败:Drift 异常 / SQLCipher 解密失败 / 文件 IO 错误。 +class CacheFailure extends Failure { + const CacheFailure({ + required super.message, + super.cause, + super.stackTrace, + }); + + @override + String get _typeName => 'CacheFailure'; + + @override + String get userMessage => '本地数据异常,请重启应用试试'; +} + +/// 兜底未知错误。 +class UnknownFailure extends Failure { + const UnknownFailure({ + required super.message, + super.cause, + super.stackTrace, + }); + + @override + String get _typeName => 'UnknownFailure'; + + @override + String get userMessage => '遇到了点问题,已自动上报'; +} diff --git a/lib/core/extensions/context_x.dart b/lib/core/extensions/context_x.dart new file mode 100644 index 0000000..fe7cd1c --- /dev/null +++ b/lib/core/extensions/context_x.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sunny_mochi/core/error/exception_mapper.dart'; +import 'package:sunny_mochi/core/widgets/app_toast.dart'; +import 'package:sunny_mochi/features/error_report/data/error_logger.dart'; + +extension BuildContextX on BuildContext { + /// 弹一个 Toast 风格的 SnackBar(对应 iOS Mkt.makeToast(...))。 + void showToast( + String message, { + ToastType type = ToastType.info, + Duration duration = const Duration(seconds: 4), + }) => AppToast.show(this, message, type: type, duration: duration); + + /// 异常 → ExceptionMapper.fromUnknown(e, st) → toast(Failure.message)。 + /// + /// 兜底替代散落的 `on Object catch (e, st) { mapper.fromUnknown; if(mounted) showToast }` + /// 模板(5+ 处 mine 表单提交流程)。 + void showError(WidgetRef ref, Object error, StackTrace stackTrace) { + final failure = + ref.read(exceptionMapperProvider).fromUnknown(error, stackTrace); + // 写入本地错误日志(fire-and-forget,不阻塞 UI) + unawaited(ref.read(errorLoggerProvider).log(failure)); + if (mounted) showToast(failure.userMessage, type: ToastType.error); + } + + /// 当前 Theme 快捷读取。 + ThemeData get theme => Theme.of(this); + ColorScheme get colors => theme.colorScheme; + TextTheme get textTheme => theme.textTheme; + + /// 屏幕信息快捷读取。 + Size get screenSize => MediaQuery.sizeOf(this); + EdgeInsets get screenPadding => MediaQuery.paddingOf(this); + double get screenWidth => screenSize.width; + double get screenHeight => screenSize.height; +} diff --git a/lib/core/extensions/datetime_x.dart b/lib/core/extensions/datetime_x.dart new file mode 100644 index 0000000..c02f411 --- /dev/null +++ b/lib/core/extensions/datetime_x.dart @@ -0,0 +1,36 @@ +import 'package:intl/intl.dart'; + +extension DateTimeX on DateTime { + /// yyyy-MM-dd + String get fmtDate => DateFormat('yyyy-MM-dd').format(this); + + /// yyyy-MM-dd HH:mm:ss + String get fmtDateTime => DateFormat('yyyy-MM-dd HH:mm:ss').format(this); + + /// HH:mm + String get fmtHm => DateFormat('HH:mm').format(this); + + /// 友好相对时间:"刚刚 / 5 分钟前 / 昨天 18:24 / 03-15 / 2024-03-15" + String get toFriendly { + final now = DateTime.now(); + final diff = now.difference(this); + + if (diff.inSeconds < 60) return '刚刚'; + if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前'; + if (diff.inHours < 24 && now.day == day) { + return '今天 $fmtHm'; + } + if (diff.inDays < 2 && now.day - day == 1) { + return '昨天 $fmtHm'; + } + if (year == now.year) { + return DateFormat('MM-dd HH:mm').format(this); + } + return fmtDate; + } +} + +extension NullableDateTimeX on DateTime? { + String get orPlaceholder => this?.fmtDateTime ?? '-'; + String get orPlaceholderDate => this?.fmtDate ?? '-'; +} diff --git a/lib/core/extensions/num_x.dart b/lib/core/extensions/num_x.dart new file mode 100644 index 0000000..f6d7395 --- /dev/null +++ b/lib/core/extensions/num_x.dart @@ -0,0 +1,25 @@ +extension NumX on num { + /// 金额格式化:1234.5 → "1,234.50" + String get yuan => + '¥${toStringAsFixed(2).replaceAllMapped( + RegExp(r'(\d)(?=(\d{3})+\.)'), + (m) => '${m[1]},', + )}'; + + /// 百分比:0.75 → "75%" + String get percent => '${(this * 100).toStringAsFixed(0)}%'; + + /// 文件大小:1024 → "1 KB" + String get bytesFmt { + if (this < 1024) return '$this B'; + if (this < 1024 * 1024) return '${(this / 1024).toStringAsFixed(1)} KB'; + if (this < 1024 * 1024 * 1024) { + return '${(this / 1024 / 1024).toStringAsFixed(1)} MB'; + } + return '${(this / 1024 / 1024 / 1024).toStringAsFixed(1)} GB'; + } +} + +extension NullableNumX on num? { + String get orPlaceholder => this?.toString() ?? '-'; +} diff --git a/lib/core/extensions/string_x.dart b/lib/core/extensions/string_x.dart new file mode 100644 index 0000000..05bdfcd --- /dev/null +++ b/lib/core/extensions/string_x.dart @@ -0,0 +1,59 @@ +/// 对应 iOS BasicModule/Extension/Extension+Optional.swift。 +extension NullableStringX on String? { + /// null 或空字符串时返回 "-"(对应 iOS orPlaceholder)。 + /// UI 层硬约束:所有 Optional 字符串展示前必须 .orPlaceholder。 + String get orPlaceholder { + if (this == null || this!.isEmpty) return '-'; + return this!; + } + + /// 简洁的非空判断(替代散落的 `s != null && s!.isNotEmpty` 三段式)。 + bool get isNotNullOrEmpty => this != null && this!.isNotEmpty; + + /// 反向:null 或空。 + bool get isNullOrEmpty => this == null || this!.isEmpty; +} + +extension NullableIntX on int? { + String get orPlaceholder => this?.toString() ?? '-'; +} + +extension NullableDoubleX on double? { + String get orPlaceholder => this?.toString() ?? '-'; +} + +extension StringX on String { + /// 首字母大写。 + String get capitalize => + isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}'; + + /// 中国大陆手机号校验(对应 iOS Validator.isValidPhone)。 + bool get isMobile => RegExp(r'^1[3-9]\d{9}$').hasMatch(this); + + /// 身份证号校验(对应 iOS Validator.isValidIDCard)。 + bool get isIdCard => RegExp(r'^\d{17}[\dXx]$').hasMatch(this); + + /// Email 简易校验。 + bool get isEmail => RegExp(r'^[\w.+-]+@([\w-]+\.)+[\w-]{2,}$').hasMatch(this); + + /// 脱敏手机号(保留首 3 + 末 4,中间 4 个 *):13800138000 → 138****8000 + String get masked { + if (length < 7) return this; + return '${substring(0, 3)}****${substring(length - 4)}'; + } + + /// 密码强度(0=无 / 1=弱 / 2=中 / 3=强)。 + /// 规则:长度 ≥6 +1;含大小写字母 +1;含数字 +1;含特殊符号 +1(最高 3)。 + int get passwordStrength { + if (length < 6) return 0; + var score = 1; + if (RegExp('[A-Z]').hasMatch(this) && RegExp('[a-z]').hasMatch(this)) { + score++; + } + if (RegExp(r'\d').hasMatch(this)) score++; + if (RegExp(r'[!@#$%^&*(),.?":{}|<>_+\-=\[\]/\\;~`]').hasMatch(this)) { + score++; + } + return score.clamp(0, 3); + } +} diff --git a/lib/core/network/api_response.dart b/lib/core/network/api_response.dart new file mode 100644 index 0000000..4efd30c --- /dev/null +++ b/lib/core/network/api_response.dart @@ -0,0 +1,53 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:sunny_mochi/core/error/failures.dart'; +import 'package:sunny_mochi/core/network/response_code.dart'; + +part 'api_response.freezed.dart'; +part 'api_response.g.dart'; + +@Freezed(genericArgumentFactories: true) +abstract class ApiResponse with _$ApiResponse { + const factory ApiResponse({ + @JsonKey(name: 'code') required String retCode, + @Default('') @JsonKey(name: 'msg') String retMsg, + @JsonKey(name: 'data') T? retData, + }) = _ApiResponse; + + factory ApiResponse.fromJson( + Map json, + T Function(Object?) fromJsonT, + ) => _$ApiResponseFromJson(json, fromJsonT); +} + +extension ApiResponseX on ApiResponse { + bool get isSuccess => ResponseCode.isSuccess(retCode); + + bool get isTokenExpired => ResponseCode.isUnauthorized(retCode); + + T? unwrap() { + if (!isSuccess) throw ServerFailure(message: retMsg, code: retCode); + return retData; + } + + void unwrapVoid() { + if (!isSuccess) throw ServerFailure(message: retMsg, code: retCode); + } + + T unwrapRequired([String errorMsg = '响应数据为空']) { + final data = unwrap(); + if (data == null) throw ServerFailure(message: errorMsg); + return data; + } +} + +/// Datasource 层 envelope 解析入口。 +/// +/// 用法:`parseEnvelope(resp.data, (j) => MyModel.fromJson(j))?.unwrap()` +ApiResponse parseEnvelope( + Map? body, + T Function(Object?) fromJsonT, { + String emptyMsg = '响应为空', +}) { + if (body == null) throw ServerFailure(message: emptyMsg); + return ApiResponse.fromJson(body, fromJsonT); +} diff --git a/lib/core/network/api_response.freezed.dart b/lib/core/network/api_response.freezed.dart new file mode 100644 index 0000000..88f5309 --- /dev/null +++ b/lib/core/network/api_response.freezed.dart @@ -0,0 +1,283 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'api_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ApiResponse { + +@JsonKey(name: 'code') String get retCode;@JsonKey(name: 'msg') String get retMsg;@JsonKey(name: 'data') T? get retData; +/// Create a copy of ApiResponse +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ApiResponseCopyWith> get copyWith => _$ApiResponseCopyWithImpl>(this as ApiResponse, _$identity); + + /// Serializes this ApiResponse to a JSON map. + Map toJson(Object? Function(T) toJsonT); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiResponse&&(identical(other.retCode, retCode) || other.retCode == retCode)&&(identical(other.retMsg, retMsg) || other.retMsg == retMsg)&&const DeepCollectionEquality().equals(other.retData, retData)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,retCode,retMsg,const DeepCollectionEquality().hash(retData)); + +@override +String toString() { + return 'ApiResponse<$T>(retCode: $retCode, retMsg: $retMsg, retData: $retData)'; +} + + +} + +/// @nodoc +abstract mixin class $ApiResponseCopyWith { + factory $ApiResponseCopyWith(ApiResponse value, $Res Function(ApiResponse) _then) = _$ApiResponseCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'code') String retCode,@JsonKey(name: 'msg') String retMsg,@JsonKey(name: 'data') T? retData +}); + + + + +} +/// @nodoc +class _$ApiResponseCopyWithImpl + implements $ApiResponseCopyWith { + _$ApiResponseCopyWithImpl(this._self, this._then); + + final ApiResponse _self; + final $Res Function(ApiResponse) _then; + +/// Create a copy of ApiResponse +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? retCode = null,Object? retMsg = null,Object? retData = freezed,}) { + return _then(_self.copyWith( +retCode: null == retCode ? _self.retCode : retCode // ignore: cast_nullable_to_non_nullable +as String,retMsg: null == retMsg ? _self.retMsg : retMsg // ignore: cast_nullable_to_non_nullable +as String,retData: freezed == retData ? _self.retData : retData // ignore: cast_nullable_to_non_nullable +as T?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ApiResponse]. +extension ApiResponsePatterns on ApiResponse { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ApiResponse value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ApiResponse() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ApiResponse value) $default,){ +final _that = this; +switch (_that) { +case _ApiResponse(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ApiResponse value)? $default,){ +final _that = this; +switch (_that) { +case _ApiResponse() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ApiResponse() when $default != null: +return $default(_that.retCode,_that.retMsg,_that.retData);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData) $default,) {final _that = this; +switch (_that) { +case _ApiResponse(): +return $default(_that.retCode,_that.retMsg,_that.retData);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData)? $default,) {final _that = this; +switch (_that) { +case _ApiResponse() when $default != null: +return $default(_that.retCode,_that.retMsg,_that.retData);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable(genericArgumentFactories: true) + +class _ApiResponse implements ApiResponse { + const _ApiResponse({@JsonKey(name: 'code') required this.retCode, @JsonKey(name: 'msg') this.retMsg = '', @JsonKey(name: 'data') this.retData}); + factory _ApiResponse.fromJson(Map json,T Function(Object?) fromJsonT) => _$ApiResponseFromJson(json,fromJsonT); + +@override@JsonKey(name: 'code') final String retCode; +@override@JsonKey(name: 'msg') final String retMsg; +@override@JsonKey(name: 'data') final T? retData; + +/// Create a copy of ApiResponse +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ApiResponseCopyWith> get copyWith => __$ApiResponseCopyWithImpl>(this, _$identity); + +@override +Map toJson(Object? Function(T) toJsonT) { + return _$ApiResponseToJson(this, toJsonT); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ApiResponse&&(identical(other.retCode, retCode) || other.retCode == retCode)&&(identical(other.retMsg, retMsg) || other.retMsg == retMsg)&&const DeepCollectionEquality().equals(other.retData, retData)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,retCode,retMsg,const DeepCollectionEquality().hash(retData)); + +@override +String toString() { + return 'ApiResponse<$T>(retCode: $retCode, retMsg: $retMsg, retData: $retData)'; +} + + +} + +/// @nodoc +abstract mixin class _$ApiResponseCopyWith implements $ApiResponseCopyWith { + factory _$ApiResponseCopyWith(_ApiResponse value, $Res Function(_ApiResponse) _then) = __$ApiResponseCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'code') String retCode,@JsonKey(name: 'msg') String retMsg,@JsonKey(name: 'data') T? retData +}); + + + + +} +/// @nodoc +class __$ApiResponseCopyWithImpl + implements _$ApiResponseCopyWith { + __$ApiResponseCopyWithImpl(this._self, this._then); + + final _ApiResponse _self; + final $Res Function(_ApiResponse) _then; + +/// Create a copy of ApiResponse +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? retCode = null,Object? retMsg = null,Object? retData = freezed,}) { + return _then(_ApiResponse( +retCode: null == retCode ? _self.retCode : retCode // ignore: cast_nullable_to_non_nullable +as String,retMsg: null == retMsg ? _self.retMsg : retMsg // ignore: cast_nullable_to_non_nullable +as String,retData: freezed == retData ? _self.retData : retData // ignore: cast_nullable_to_non_nullable +as T?, + )); +} + + +} + +// dart format on diff --git a/lib/core/network/api_response.g.dart b/lib/core/network/api_response.g.dart new file mode 100644 index 0000000..e2336e7 --- /dev/null +++ b/lib/core/network/api_response.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ApiResponse _$ApiResponseFromJson( + Map json, + T Function(Object? json) fromJsonT, +) => _ApiResponse( + retCode: json['code'] as String, + retMsg: json['msg'] as String? ?? '', + retData: _$nullableGenericFromJson(json['data'], fromJsonT), +); + +Map _$ApiResponseToJson( + _ApiResponse instance, + Object? Function(T value) toJsonT, +) => { + 'code': instance.retCode, + 'msg': instance.retMsg, + 'data': _$nullableGenericToJson(instance.retData, toJsonT), +}; + +T? _$nullableGenericFromJson( + Object? input, + T Function(Object? json) fromJson, +) => input == null ? null : fromJson(input); + +Object? _$nullableGenericToJson( + T? input, + Object? Function(T value) toJson, +) => input == null ? null : toJson(input); diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart new file mode 100644 index 0000000..e0d4614 --- /dev/null +++ b/lib/core/network/dio_client.dart @@ -0,0 +1,56 @@ +import 'package:dio/dio.dart'; +import 'package:sunny_mochi/core/config/api_config.dart'; +import 'package:sunny_mochi/core/config/env.dart'; +import 'package:sunny_mochi/core/network/interceptors/auth_interceptor.dart'; +import 'package:sunny_mochi/core/network/interceptors/auth_logout_interceptor.dart'; +import 'package:sunny_mochi/core/network/interceptors/cert_pinning_interceptor.dart'; +import 'package:sunny_mochi/core/network/interceptors/error_interceptor.dart'; +import 'package:sunny_mochi/core/network/interceptors/log_interceptor.dart'; +import 'package:sunny_mochi/core/network/interceptors/retry_interceptor.dart'; +import 'package:sunny_mochi/core/network/interceptors/token_refresh_interceptor.dart'; +import 'package:sunny_mochi/core/network/mock/dio_mock_adapter.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'dio_client.g.dart'; + +/// 完整 7 拦截器栈: +/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log +/// +/// 顺序敏感: +/// - CertPinning 最早(在请求出去前校验) +/// - Auth 注 Token,TokenRefresh 在 401 时拦截再发 +/// - Retry 在网络/5xx 错误时退避重试 +/// - Error 把所有 DioException 映射为应用层 Failure +/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure +/// - Log 最后,记录最终结果(Release 包 noop) +@Riverpod(keepAlive: true) +Dio dioClient(Ref ref) { + final dio = Dio( + BaseOptions( + baseUrl: ApiConfig.baseUrl, + connectTimeout: ApiConfig.connectTimeout, + receiveTimeout: ApiConfig.receiveTimeout, + sendTimeout: ApiConfig.sendTimeout, + headers: const { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + + dio.interceptors.addAll([ + CertPinningInterceptor(), + AuthInterceptor(ref), + TokenRefreshInterceptor(ref, dio), + RetryInterceptor(dio), + ErrorInterceptor(), + AuthLogoutInterceptor(ref), + buildNetworkLogInterceptor(), + ]); + + if (Env.useMock) { + dio.httpClientAdapter = buildDefaultMockAdapter(); + } + + return dio; +} diff --git a/lib/core/network/dio_client.g.dart b/lib/core/network/dio_client.g.dart new file mode 100644 index 0000000..fb9744c --- /dev/null +++ b/lib/core/network/dio_client.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'dio_client.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// 完整 7 拦截器栈: +/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log +/// +/// 顺序敏感: +/// - CertPinning 最早(在请求出去前校验) +/// - Auth 注 Token,TokenRefresh 在 401 时拦截再发 +/// - Retry 在网络/5xx 错误时退避重试 +/// - Error 把所有 DioException 映射为应用层 Failure +/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure +/// - Log 最后,记录最终结果(Release 包 noop) + +@ProviderFor(dioClient) +final dioClientProvider = DioClientProvider._(); + +/// 完整 7 拦截器栈: +/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log +/// +/// 顺序敏感: +/// - CertPinning 最早(在请求出去前校验) +/// - Auth 注 Token,TokenRefresh 在 401 时拦截再发 +/// - Retry 在网络/5xx 错误时退避重试 +/// - Error 把所有 DioException 映射为应用层 Failure +/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure +/// - Log 最后,记录最终结果(Release 包 noop) + +final class DioClientProvider extends $FunctionalProvider + with $Provider { + /// 完整 7 拦截器栈: + /// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log + /// + /// 顺序敏感: + /// - CertPinning 最早(在请求出去前校验) + /// - Auth 注 Token,TokenRefresh 在 401 时拦截再发 + /// - Retry 在网络/5xx 错误时退避重试 + /// - Error 把所有 DioException 映射为应用层 Failure + /// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure + /// - Log 最后,记录最终结果(Release 包 noop) + DioClientProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'dioClientProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$dioClientHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Dio create(Ref ref) { + return dioClient(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Dio value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$dioClientHash() => r'f740b06528c313c24f8288686585bdf379411fa2'; diff --git a/lib/core/network/interceptors/auth_interceptor.dart b/lib/core/network/interceptors/auth_interceptor.dart new file mode 100644 index 0000000..6addc04 --- /dev/null +++ b/lib/core/network/interceptors/auth_interceptor.dart @@ -0,0 +1,37 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sunny_mochi/core/network/interceptors/token_refresh_interceptor.dart' + show TokenRefreshInterceptor; +import 'package:sunny_mochi/core/storage/secure_storage.dart' + show secureStorageProvider; + +/// 自动注入 sa-token 鉴权 header。 +/// +/// **后端是 sa-token 框架** — header 名是动态的(登录响应 `saTokenInfo.tokenName` 返回, +/// 如 `satoken`),不是固定的 `Authorization: Bearer xxx`。 +/// Token 失效(401)的处理由 [TokenRefreshInterceptor] 接管,本拦截器只管注入。 +class AuthInterceptor extends Interceptor { + AuthInterceptor(this._ref); + + final Ref _ref; + + /// 兜底 header 名 — 当 SecureStorage 里没有 tokenName 时使用 sa-token 框架默认值。 + static const String _fallbackTokenName = 'satoken'; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final skipAuth = options.extra['skip_auth'] == true; + if (skipAuth) return handler.next(options); + + final storage = _ref.read(secureStorageProvider); + final token = await storage.getToken(); + if (token != null && token.isNotEmpty) { + final tokenName = await storage.getTokenName() ?? _fallbackTokenName; + options.headers[tokenName] = token; + } + handler.next(options); + } +} diff --git a/lib/core/network/interceptors/auth_logout_interceptor.dart b/lib/core/network/interceptors/auth_logout_interceptor.dart new file mode 100644 index 0000000..166a6de --- /dev/null +++ b/lib/core/network/interceptors/auth_logout_interceptor.dart @@ -0,0 +1,46 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sunny_mochi/core/error/failures.dart'; +import 'package:sunny_mochi/core/observability/talker_setup.dart'; +import 'package:sunny_mochi/core/storage/secure_storage.dart' + show secureStorageProvider; +import 'package:sunny_mochi/features/auth/presentation/notifiers/auth_status_provider.dart'; + +/// 全局 AuthFailure → markLoggedOut 副作用拦截器。 +/// +/// 链路: +/// 1. ErrorInterceptor 已经把 [DioException] 映射为 [Failure] 写回 `e.error` +/// 2. 本拦截器(必须放在 ErrorInterceptor **之后**)检查 `e.error` +/// 3. 命中 [AuthFailure] 的 unauthorized / refreshFailed 时: +/// - 清空 SecureStorage +/// - markLoggedOut → router refreshListenable 触发 → 自动跳 /login +/// 4. forbidden 不清登录态,仅由 UI 处理 +class AuthLogoutInterceptor extends Interceptor { + AuthLogoutInterceptor(this._ref); + + final Ref _ref; + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + final failure = err.error; + if (failure is AuthFailure && _shouldLogout(failure.kind)) { + appTalker.warning( + '[AuthLogout] AuthFailure(${failure.kind.name}) → markLoggedOut + clearAll', + ); + try { + await _ref.read(secureStorageProvider).clearAll(); + } on Object catch (e, st) { + appTalker.warning('[AuthLogout] clearAll 失败', e, st); + } + _ref.read(authStatusProvider).markLoggedOut(); + } + handler.next(err); + } + + bool _shouldLogout(AuthFailureKind kind) => + kind == AuthFailureKind.unauthorized || + kind == AuthFailureKind.refreshFailed; +} diff --git a/lib/core/network/interceptors/cert_pinning_interceptor.dart b/lib/core/network/interceptors/cert_pinning_interceptor.dart new file mode 100644 index 0000000..b30725d --- /dev/null +++ b/lib/core/network/interceptors/cert_pinning_interceptor.dart @@ -0,0 +1,23 @@ +import 'package:dio/dio.dart'; +import 'package:sunny_mochi/core/config/env.dart'; +import 'package:sunny_mochi/core/observability/talker_setup.dart'; + +/// TLS 证书绑定(Pinning)拦截器。 +/// +/// 当前为**占位实现**:[Env.pinnedFingerprints] 为空时跳过校验, +/// 非空时由 HttpClientAdapter 层做真实 SHA-256 比对。 +/// 生产指纹在环境就绪后替换 Dio.httpClientAdapter 接入。 +class CertPinningInterceptor extends Interceptor { + CertPinningInterceptor(); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (Env.pinnedFingerprints.isEmpty) { + return handler.next(options); + } + appTalker.verbose( + '[CertPinning] ${options.uri.host} → 准备校验(${Env.pinnedFingerprints.length} 指纹)', + ); + handler.next(options); + } +} diff --git a/lib/core/network/interceptors/error_interceptor.dart b/lib/core/network/interceptors/error_interceptor.dart new file mode 100644 index 0000000..cf3accc --- /dev/null +++ b/lib/core/network/interceptors/error_interceptor.dart @@ -0,0 +1,25 @@ +import 'package:dio/dio.dart'; +import 'package:sunny_mochi/core/error/exception_mapper.dart'; +import 'package:sunny_mochi/core/error/failures.dart' show Failure; + +/// 把 [DioException] 映射为应用层 [Failure],写回 `error.error` 字段。 +/// +/// 后续上层只需 catch DioException 然后读 `e.error as Failure`, +/// 或者在 Repository 层 catch DioException 后调用 [ExceptionMapper.fromDio]。 +class ErrorInterceptor extends Interceptor { + ErrorInterceptor({ExceptionMapper? mapper}) + : _mapper = mapper ?? const ExceptionMapper(); + + final ExceptionMapper _mapper; + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final failure = _mapper.fromDio(err); + handler.next( + err.copyWith( + error: failure, + message: failure.message, + ), + ); + } +} diff --git a/lib/core/network/interceptors/log_interceptor.dart b/lib/core/network/interceptors/log_interceptor.dart new file mode 100644 index 0000000..30225da --- /dev/null +++ b/lib/core/network/interceptors/log_interceptor.dart @@ -0,0 +1,13 @@ +import 'package:dio/dio.dart'; +import 'package:sunny_mochi/core/config/env.dart'; +import 'package:sunny_mochi/core/observability/talker_setup.dart'; +import 'package:talker_dio_logger/talker_dio_logger.dart'; + +/// 网络请求日志:仅在 [Env.enableDevPanel](debug 或内测包)启用, +/// Release 包不记录请求/响应 body(含敏感数据)。 +Interceptor buildNetworkLogInterceptor() { + if (!Env.enableDevPanel) { + return InterceptorsWrapper(); + } + return TalkerDioLogger(talker: appTalker); +} diff --git a/lib/core/network/interceptors/retry_interceptor.dart b/lib/core/network/interceptors/retry_interceptor.dart new file mode 100644 index 0000000..1dd986d --- /dev/null +++ b/lib/core/network/interceptors/retry_interceptor.dart @@ -0,0 +1,51 @@ +import 'package:dio/dio.dart'; +import 'package:sunny_mochi/core/observability/talker_setup.dart'; + +/// 408/429/500/502/503/504 指数退避重试,最多 [maxRetries] 次。 +class RetryInterceptor extends Interceptor { + RetryInterceptor(this._dio, {this.maxRetries = 3}); + + final Dio _dio; + final int maxRetries; + + static const _retryStatuses = {408, 429, 500, 502, 503, 504}; + static const Set _retryDioTypes = { + DioExceptionType.connectionTimeout, + DioExceptionType.receiveTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.connectionError, + }; + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + final status = err.response?.statusCode; + final attempt = (err.requestOptions.extra['_retry_attempt'] as int?) ?? 0; + + final shouldRetry = + (status != null && _retryStatuses.contains(status)) || + _retryDioTypes.contains(err.type); + + if (!shouldRetry || attempt >= maxRetries) { + return handler.next(err); + } + + // 指数退避:200ms → 400ms → 800ms + final delayMs = 200 * (1 << attempt); + appTalker.info( + '[Retry] ${err.requestOptions.uri.path} 第 ${attempt + 1}/$maxRetries 次重试(${delayMs}ms 后)', + ); + await Future.delayed(Duration(milliseconds: delayMs)); + + try { + final response = await _dio.fetch( + err.requestOptions..extra['_retry_attempt'] = attempt + 1, + ); + return handler.resolve(response); + } on DioException catch (e) { + return handler.next(e); + } + } +} diff --git a/lib/core/network/interceptors/token_refresh_interceptor.dart b/lib/core/network/interceptors/token_refresh_interceptor.dart new file mode 100644 index 0000000..2fedba1 --- /dev/null +++ b/lib/core/network/interceptors/token_refresh_interceptor.dart @@ -0,0 +1,141 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sunny_mochi/core/config/api_paths.dart'; +import 'package:sunny_mochi/core/network/response_code.dart'; +import 'package:sunny_mochi/core/observability/talker_setup.dart'; +import 'package:sunny_mochi/core/storage/secure_storage.dart' + show secureStorageProvider; + +/// 401 时尝试 refresh 一次 → 重放原请求;refresh 也失败才清空 token。 +/// +/// **互斥实现**:用 [Completer] 显式表达互斥语义,避免并发 401 重复消耗 RefreshToken: +/// - 第一个 401 进入 → new Completer + 把 future 缓存在 [_refreshing] +/// - 后续并发 401 → await 同一个 [_refreshing] 的 future +/// - refresh 完成后 [_refreshing] 清空,新一轮 401 才会再次触发 +/// +/// **业务码分类**: +/// - status==401 + body.retCode ∈ token 类码 → refresh + 重放 +/// - status==401 + body.retCode 是其他业务码(如权限不足)→ 透传,不消耗 RefreshToken +/// - status==401 + 无 body 或无 retCode → 透传,不消耗 RefreshToken +class TokenRefreshInterceptor extends Interceptor { + TokenRefreshInterceptor(this._ref, this._dio); + + final Ref _ref; + final Dio _dio; + + Completer? _refreshing; + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + final status = err.response?.statusCode; + final retried = err.requestOptions.extra['_token_refreshed'] == true; + final isAuthEndpoint = err.requestOptions.extra['skip_auth'] == true; + + if (status != 401 || retried || isAuthEndpoint) { + return handler.next(err); + } + + final retCode = _extractRetCode(err.response?.data); + if (!ResponseCode.isTokenRefreshable(retCode)) { + err.requestOptions.extra['_auth_not_refreshable'] = true; + return handler.next(err); + } + + final storage = _ref.read(secureStorageProvider); + final refreshToken = await storage.getRefreshToken(); + if (refreshToken == null || refreshToken.isEmpty) { + await storage.clearAll(); + return handler.next(err); + } + + final pending = _refreshing; + final bool ok; + if (pending != null) { + ok = await pending.future; + } else { + final completer = Completer(); + _refreshing = completer; + bool result = false; + try { + result = await _runRefresh(refreshToken); + } finally { + // 先清空 _refreshing,再 complete: + // 确保新到来的 401 能进入下一轮 refresh,而当前 waiters 仍通过 + // 已持有的 completer 引用得到结果(不受 _refreshing = null 影响)。 + _refreshing = null; + completer.complete(result); + } + ok = result; + } + + if (!ok) { + err.requestOptions.extra['_auth_refresh_failed'] = true; + return handler.next(err); + } + + try { + final newToken = await storage.getToken(); + if (newToken != null && newToken.isNotEmpty) { + final tokenName = await storage.getTokenName() ?? 'satoken'; + err.requestOptions.headers[tokenName] = newToken; + } + final retriedResp = await _dio.fetch( + err.requestOptions..extra['_token_refreshed'] = true, + ); + return handler.resolve(retriedResp); + } on DioException catch (e) { + return handler.next(e); + } + } + + Future _runRefresh(String refreshToken) async { + try { + return await _doRefresh(refreshToken); + } on Object catch (e, st) { + appTalker.warning('[TokenRefresh] 刷新失败:$e', e, st); + await _ref.read(secureStorageProvider).clearAll(); + return false; + } + } + + Future _doRefresh(String refreshToken) async { + final resp = await _dio.post>( + ApiPaths.authRefresh, + data: {'refresh_token': refreshToken}, + options: Options(extra: {'skip_auth': true}), + ); + final body = resp.data; + final retCode = body?['code'] as String? ?? body?['retCode'] as String?; + if (retCode != '00000') return false; + + final retData = + (body?['data'] ?? body?['retData']) as Map?; + final access = retData?['access_token'] as String?; + final refresh = retData?['refresh_token'] as String?; + if (access == null || access.isEmpty) return false; + + final storage = _ref.read(secureStorageProvider); + await storage.setToken(access); + if (refresh != null && refresh.isNotEmpty) { + await storage.setRefreshToken(refresh); + } + // tokenName 可能随 refresh 响应更新(对齐登录路径 auth_repository_impl._persistUser) + final newTokenName = retData?['token_name'] as String? + ?? retData?['tokenName'] as String?; + if (newTokenName != null && newTokenName.isNotEmpty) { + await storage.setTokenName(newTokenName); + } + return true; + } + + String? _extractRetCode(Object? body) { + if (body is! Map) return null; + final raw = body['retCode'] ?? body['code'] ?? body['errorCode']; + return raw is String ? raw : raw?.toString(); + } +} diff --git a/lib/core/network/mock/dio_mock_adapter.dart b/lib/core/network/mock/dio_mock_adapter.dart new file mode 100644 index 0000000..794cfe7 --- /dev/null +++ b/lib/core/network/mock/dio_mock_adapter.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:sunny_mochi/core/config/api_paths.dart'; +import 'package:sunny_mochi/core/config/env.dart' show Env; +import 'package:sunny_mochi/core/network/mock/mock_response_loader.dart'; +import 'package:sunny_mochi/core/observability/talker_setup.dart'; + +/// 把 Dio 请求拦截,根据 path + method 返回 fixtures 中的预设响应。 +/// +/// 启用方式:在 dio_client.dart 中检查 [Env.useMock]: +/// ```dart +/// if (Env.useMock) { +/// dio.httpClientAdapter = buildDefaultMockAdapter(); +/// } +/// ``` +class DioMockAdapter implements HttpClientAdapter { + DioMockAdapter({this.delay = const Duration(milliseconds: 200)}); + + final Map _routes = {}; + final Duration delay; + bool _closed = false; + + /// 注册一条 mock 路由:method+path → fixtures 文件名(不含 .json 后缀)。 + void register(String method, String path, String fixtureName) { + _routes['${method.toUpperCase()} $path'] = fixtureName; + } + + @override + void close({bool force = false}) { + _closed = true; + } + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + if (_closed) { + throw DioException(requestOptions: options, message: 'adapter closed'); + } + + await Future.delayed(delay); + + final key = '${options.method} ${options.path}'; + final exact = _routes[key]; + var matched = exact; + if (matched == null) { + for (final entry in _routes.entries) { + if (key.startsWith(entry.key)) { + matched = entry.value; + break; + } + } + } + + if (matched == null) { + appTalker.warning('[Mock] 未注册 fixture:$key — 返回空 envelope'); + final body = jsonEncode({ + 'code': '00000', + 'msg': 'mock-empty', + 'data': null, + }); + return ResponseBody.fromString( + body, + 200, + headers: {'content-type': ['application/json']}, + ); + } + + appTalker.verbose('[Mock] $key → fixtures/$matched.json'); + final json = await MockResponseLoader.load(matched); + final body = jsonEncode(json); + return ResponseBody.fromString( + body, + 200, + headers: {'content-type': ['application/json']}, + ); + } +} + +/// 默认的全局 mock 路由表。 +// TODO: 按项目实际 API 路径扩展此路由表 +DioMockAdapter buildDefaultMockAdapter() { + return DioMockAdapter() + // === Auth === + ..register('POST', ApiPaths.authLoginSms, 'auth/login_success') + ..register('POST', ApiPaths.authLoginPwd, 'auth/login_success') + ..register('POST', ApiPaths.authSmsSend, 'auth/send_sms_code_success') + ..register('POST', ApiPaths.authRefresh, 'auth/refresh_success') + ..register('POST', ApiPaths.crashReport, 'common/generic_success') + // === User / Mine === + ..register('GET', ApiPaths.userProfile, 'mine/profile') + ..register('POST', ApiPaths.userChangePassword, 'common/generic_success') + ..register('POST', ApiPaths.userAvatarUpload, 'mine/avatar_upload'); +} diff --git a/lib/core/network/mock/mock_response_loader.dart b/lib/core/network/mock/mock_response_loader.dart new file mode 100644 index 0000000..de76b80 --- /dev/null +++ b/lib/core/network/mock/mock_response_loader.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart' show rootBundle; + +/// 从 assets/fixtures/ 加载 JSON 响应。 +/// +/// 使用: +/// ```dart +/// final body = await MockResponseLoader.load('auth/login_success'); +/// ``` +abstract class MockResponseLoader { + static final Map> _cache = {}; + + static Future> load(String name) async { + if (_cache.containsKey(name)) return _cache[name]!; + final raw = await rootBundle.loadString('assets/fixtures/$name.json'); + final json = jsonDecode(raw) as Map; + _cache[name] = json; + return json; + } +} diff --git a/lib/core/network/network_exception.dart b/lib/core/network/network_exception.dart new file mode 100644 index 0000000..7cc91fa --- /dev/null +++ b/lib/core/network/network_exception.dart @@ -0,0 +1,25 @@ +sealed class NetworkException implements Exception { + const NetworkException(this.message); + final String message; +} + +class NoNetworkException extends NetworkException { + const NoNetworkException() : super('网络不可用,请检查网络连接'); +} + +class TimeoutException extends NetworkException { + const TimeoutException() : super('请求超时,请稍后重试'); +} + +class ServerException extends NetworkException { + const ServerException([super.msg = '服务器异常,请稍后重试']); +} + +class UnauthorizedException extends NetworkException { + const UnauthorizedException() : super('登录已过期,请重新登录'); +} + +class ApiException extends NetworkException { + const ApiException(super.message, this.code); + final String code; +} diff --git a/lib/core/network/response_code.dart b/lib/core/network/response_code.dart new file mode 100644 index 0000000..c0c02ee --- /dev/null +++ b/lib/core/network/response_code.dart @@ -0,0 +1,50 @@ +/// 服务端业务错误码常量。 +/// +/// envelope 结构:`{retCode: String, retMsg: String, retData: T?}`(key map: code/msg/data) +/// 错误码格式:`A04XX` = 客户端错 / `A05XX` = 服务端错 / `99999` = 兜底 / `00000` = 成功 +abstract class ResponseCode { + /// 成功 + static const String success = '00000'; + + /// 兜底请求失败 + static const String generic = '99999'; + + /// 服务端 500 类 + static const String serverError = 'A0500'; + static const String systemError = 'A0501'; + + /// 客户端 400 类 + static const String paramError = 'A0400'; + static const String paramMissing = 'A0402'; + static const String resourceNotFound = 'A0404'; + + /// 鉴权过期 → 跳登录 + static const String unauthorized = 'A0401'; + + /// Token 类业务码(占位 — 待后端确认实际值后改这里一处)。 + // TODO: 后端确认后更新 tokenExpired / tokenInvalid 实际字符串值 + static const String tokenExpired = 'TOKEN_EXPIRED'; + static const String tokenInvalid = 'TOKEN_INVALID'; + + /// 仅 token 类业务码才允许 TokenRefreshInterceptor 触发 refresh。 + static bool isTokenRefreshable(String? code) => + code == unauthorized || code == tokenExpired || code == tokenInvalid; + + static bool isSuccess(String? code) => code == success; + + static bool isUnauthorized(String? code) => code == unauthorized; + + /// 用户友好的错误提示。 + static String friendlyMessage(String? code, String fallback) { + return switch (code) { + success => '', + unauthorized => '登录已过期,请重新登录', + paramError => '请求参数有误,请稍候重试', + paramMissing => '请求参数不完整,请稍候重试', + resourceNotFound => '请求的资源不存在', + serverError || systemError => '服务器开小差啦,请稍候重试', + generic => '请求失败', + _ => fallback, + }; + } +} diff --git a/lib/core/observability/sentry_setup.dart b/lib/core/observability/sentry_setup.dart new file mode 100644 index 0000000..9057bf5 --- /dev/null +++ b/lib/core/observability/sentry_setup.dart @@ -0,0 +1,160 @@ +import 'package:flutter/foundation.dart'; +import 'package:sunny_mochi/core/config/env.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +/// 包装 [SentryFlutter.init],统一注入: +/// - DSN 来自 [Env.sentryDsn](空 DSN 跳过实际上报,便于 P0 兜底) +/// - PII 脱敏(健康类 App 强约束,详见 docs/flutter-architecture-design.md §十一.5) +/// - 屏蔽 screenshot / view hierarchy(含敏感页面) +/// +/// 调用方在 main.dart 包一层: +/// ```dart +/// await SentrySetup.init(appRunner: () => runApp(...)); +/// ``` +class SentrySetup { + static Future init({required Future Function() appRunner}) async { + if (Env.sentryDsn.isEmpty) { + // P0 / 本地开发兜底:未配置 DSN 时直接跑应用,不绕道 Sentry init + if (kDebugMode) { + debugPrint('[Sentry] DSN 为空,跳过 init(仅本地,生产必须配置)'); + } + await appRunner(); + return; + } + + await SentryFlutter.init( + (options) { + options + ..dsn = Env.sentryDsn + ..environment = Env.name + ..tracesSampleRate = Env.isProd ? 0.2 : 1.0 + ..debug = kDebugMode + // === 健康数据 PII 脱敏(强约束)=== + ..sendDefaultPii = false + ..beforeSend = scrubPii; + }, + appRunner: appRunner, + ); + } + + /// 丢弃可能含敏感数据的字段,仅保留错误归因所需的元信息。 + /// + /// 脱敏覆盖: + /// - `request.data` / `request.cookies`(可能含登录密码 / token / 手机号) + /// - `request.headers` 仅保留白名单 + /// - `user.email` / `user.ipAddress`(仅保留匿名 userId) + /// - `breadcrumbs.message` 内 token / 手机号 / 身份证 / 长 base64 串掩盖 + /// + /// **可见**:visible for testing — 直接调用以验证脱敏行为。 + @visibleForTesting + static SentryEvent? scrubPii(SentryEvent event, Hint hint) { + // Sentry 9.x:直接 mutate 字段(copyWith 已 deprecated)。 + event + ..request = _scrubRequest(event.request) + ..user = _scrubUser(event.user) + ..breadcrumbs = _scrubBreadcrumbs(event.breadcrumbs) + // R-SEC-3 / 2026-05-10:extra 字段防御性清理 — + // SDK 自动 instrumentation(frame tracking 等)可能写入 Widget 名称 / 路由参数, + // 路由参数可能含手机号 / userId。用白名单过滤而非全清,保留调试上下文。 + ..extra = _scrubExtra(event.extra); + return event; + } + + static SentryRequest? _scrubRequest(SentryRequest? request) { + if (request == null) return null; + return SentryRequest( + url: request.url, + method: request.method, + // queryString / cookies / data 强制清空(可能含 token / 手机号 / 密码 / 健康数据) + headers: _scrubHeaders(request.headers), + apiTarget: request.apiTarget, + ); + } + + static SentryUser? _scrubUser(SentryUser? user) { + if (user == null) return null; + // 仅保留匿名 userId(要求上层调用方已哈希);email/IP 强制清空 + return SentryUser(id: user.id); + } + + /// 仅保留非敏感 header(白名单策略)。 + static Map? _scrubHeaders(Map? headers) { + if (headers == null) return null; + const allow = {'content-type', 'accept', 'user-agent', 'x-request-id'}; + return { + for (final e in headers.entries) + if (allow.contains(e.key.toLowerCase())) e.key: e.value, + }; + } + + /// 把 breadcrumbs 中 message 内的 token / 手机号 / 身份证 / 长 base64 串掩盖。 + static List? _scrubBreadcrumbs(List? crumbs) { + if (crumbs == null) return null; + return crumbs + .map( + (c) => Breadcrumb( + timestamp: c.timestamp, + message: c.message == null ? null : maskSensitive(c.message!), + category: c.category, + level: c.level, + type: c.type, + // data 中也可能含敏感字段,按白名单清空(仅保留 method/status_code/url 这种) + data: _scrubBreadcrumbData(c.data), + ), + ) + .toList(); + } + + static Map? _scrubBreadcrumbData(Map? data) { + if (data == null) return null; + const allow = {'method', 'status_code', 'url', 'reason'}; + return { + for (final e in data.entries) + if (allow.contains(e.key)) + e.key: e.value is String ? maskSensitive(e.value as String) : e.value, + }; + } + + /// 仅保留非敏感 extra(白名单 + 字符串值掩盖)。 + /// + /// 业务侧 `Sentry.captureException(extra: {...})` 应主动避免传 PII; + /// 此函数作为防御层,覆盖 SDK 自动写入场景。 + static Map? _scrubExtra(Map? extra) { + if (extra == null) return null; + // 路由 / Widget 上下文是调试有用且非 PII 的元信息,保留; + // 其他业务自定义字段全部清除(业务方明确知道字段含义时应在 captureException 时 + // 自行 mask 后放进来,再加进 allow 列表)。 + const allow = {'route', 'route_name', 'widget', 'screen', 'flavor'}; + return { + for (final e in extra.entries) + if (allow.contains(e.key)) + e.key: e.value is String ? maskSensitive(e.value as String) : e.value, + }; + } + + /// 把字符串中的常见敏感模式替换为 `***`。 + /// + /// 模式: + /// - 手机号:11 位 1 开头数字 + /// - 身份证:18 位(最后一位可 X) + /// - JWT / 长 base64 token:连续 32+ 个 base64url 字符 + /// - 形如 `Bearer xxx` / `token=xxx` 的整段 + @visibleForTesting + static String maskSensitive(String input) { + return input + // Bearer / Authorization-style + .replaceAll( + RegExp( + r'(?:bearer\s+|token[=:]\s*|access_token[=:]\s*|refresh_token[=:]\s*)[A-Za-z0-9._\-+/=]{12,}', + caseSensitive: false, + ), + '***', + ) + // 长 base64 / JWT + .replaceAll(RegExp(r'[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}'), '***') + // 中国大陆手机号 + .replaceAll(RegExp(r'(? + with $Provider { + AppRouterProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'appRouterProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$appRouterHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + GoRouter create(Ref ref) { + return appRouter(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GoRouter value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$appRouterHash() => r'6f5b7a9f1595fbf5343fdaf160df95a775d77b57'; diff --git a/lib/core/router/routes.dart b/lib/core/router/routes.dart new file mode 100644 index 0000000..6a8c521 --- /dev/null +++ b/lib/core/router/routes.dart @@ -0,0 +1,89 @@ +import 'package:go_router/go_router.dart'; +import 'package:sunny_mochi/core/widgets/shell_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:sunny_mochi/features/auth/presentation/pages/login_page.dart'; +import 'package:sunny_mochi/features/dev_panel/presentation/pages/dev_panel_page.dart'; +import 'package:sunny_mochi/features/error_report/presentation/error_report_page.dart'; + +part 'routes.g.dart'; + +// ── StatefulShellRoute(底部导航 2 Tab)───────────────────────────────────── + +@TypedStatefulShellRoute( + branches: [ + TypedStatefulShellBranch( + routes: [TypedGoRoute(path: '/home')], + ), + TypedStatefulShellBranch( + routes: [TypedGoRoute(path: '/mine')], + ), + ], +) +class ShellRouteData extends StatefulShellRouteData { + const ShellRouteData(); + + @override + Widget builder( + BuildContext context, + GoRouterState state, + StatefulNavigationShell shell, + ) => + ShellScaffold(shell: shell); +} + +class HomeBranch extends StatefulShellBranchData { + const HomeBranch(); +} + +class MineBranch extends StatefulShellBranchData { + const MineBranch(); +} + +// ── Shell 叶子路由(TODO: 业务项目替换为真实页面)─────────────────────────── + +class HomeRoute extends GoRouteData with $HomeRoute { + const HomeRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => const Scaffold( + body: Center(child: Text('Home — TODO: Replace with HomePage')), + ); +} + +class MineRoute extends GoRouteData with $MineRoute { + const MineRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => const Scaffold( + body: Center(child: Text('Mine — TODO: Replace with MinePage')), + ); +} + +// ── 独立路由(Shell 之外)──────────────────────────────────────────────────── + +@TypedGoRoute(path: '/login') +class LoginRoute extends GoRouteData with $LoginRoute { + const LoginRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const LoginPage(); +} + +@TypedGoRoute(path: '/dev') +class DevPanelRoute extends GoRouteData with $DevPanelRoute { + const DevPanelRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const DevPanelPage(); +} + +@TypedGoRoute(path: '/error-report') +class ErrorReportRoute extends GoRouteData with $ErrorReportRoute { + const ErrorReportRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const ErrorReportPage(); +} diff --git a/lib/core/router/routes.g.dart b/lib/core/router/routes.g.dart new file mode 100644 index 0000000..c27ce7a --- /dev/null +++ b/lib/core/router/routes.g.dart @@ -0,0 +1,147 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'routes.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $shellRouteData, + $loginRoute, + $devPanelRoute, + $errorReportRoute, +]; + +RouteBase get $shellRouteData => StatefulShellRouteData.$route( + factory: $ShellRouteDataExtension._fromState, + branches: [ + StatefulShellBranchData.$branch( + routes: [ + GoRouteData.$route(path: '/home', factory: $HomeRoute._fromState), + ], + ), + StatefulShellBranchData.$branch( + routes: [ + GoRouteData.$route(path: '/mine', factory: $MineRoute._fromState), + ], + ), + ], +); + +extension $ShellRouteDataExtension on ShellRouteData { + static ShellRouteData _fromState(GoRouterState state) => + const ShellRouteData(); +} + +mixin $HomeRoute on GoRouteData { + static HomeRoute _fromState(GoRouterState state) => const HomeRoute(); + + @override + String get location => GoRouteData.$location('/home'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin $MineRoute on GoRouteData { + static MineRoute _fromState(GoRouterState state) => const MineRoute(); + + @override + String get location => GoRouteData.$location('/mine'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $loginRoute => + GoRouteData.$route(path: '/login', factory: $LoginRoute._fromState); + +mixin $LoginRoute on GoRouteData { + static LoginRoute _fromState(GoRouterState state) => const LoginRoute(); + + @override + String get location => GoRouteData.$location('/login'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $devPanelRoute => + GoRouteData.$route(path: '/dev', factory: $DevPanelRoute._fromState); + +mixin $DevPanelRoute on GoRouteData { + static DevPanelRoute _fromState(GoRouterState state) => const DevPanelRoute(); + + @override + String get location => GoRouteData.$location('/dev'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $errorReportRoute => GoRouteData.$route( + path: '/error-report', + factory: $ErrorReportRoute._fromState, +); + +mixin $ErrorReportRoute on GoRouteData { + static ErrorReportRoute _fromState(GoRouterState state) => + const ErrorReportRoute(); + + @override + String get location => GoRouteData.$location('/error-report'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart new file mode 100644 index 0000000..08a37ea --- /dev/null +++ b/lib/core/storage/app_database.dart @@ -0,0 +1,152 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sunny_mochi/core/storage/db_key_provider.dart'; +import 'package:sunny_mochi/core/storage/tables/error_logs_table.dart'; +import 'package:sunny_mochi/core/storage/tables/users_table.dart'; +import 'package:sunny_mochi/core/sync/sync_status.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart'; +// ignore: depend_on_referenced_packages — sqlite3 是 sqlcipher_flutter_libs 的传递依赖 +import 'package:sqlite3/open.dart' as sqlite3_open; +// ignore: depend_on_referenced_packages — sqlite3 是 sqlcipher_flutter_libs 的传递依赖 +import 'package:sqlite3/sqlite3.dart' as sqlite3_pkg; + +part 'app_database.g.dart'; + +/// SQLCipher 库注册函数,同时用于主 Isolate 和后台 Isolate。 +/// +/// 必须是**顶层函数**(不能是匿名闭包)——Drift 通过 Isolate.spawn 将其传递给 +/// 后台 Isolate 时走 SendPort.send 序列化路径,顶层函数引用保证可靠传递; +/// 匿名闭包在某些 Drift 2.x / Dart VM 版本组合下会静默失效(无错误,仅 setup +/// 不执行),导致后台 Isolate 继续使用系统 plain sqlite3。 +void _sqlCipherIsolateSetup() { + if (kIsWeb) return; + if (Platform.isAndroid) { + sqlite3_open.open.overrideFor( + sqlite3_open.OperatingSystem.android, + openCipherOnAndroid, + ); + } else if (Platform.isIOS) { + sqlite3_open.open.overrideFor( + sqlite3_open.OperatingSystem.iOS, + DynamicLibrary.process, + ); + } else if (Platform.isMacOS) { + sqlite3_open.open.overrideFor( + sqlite3_open.OperatingSystem.macOS, + DynamicLibrary.process, + ); + } +} + +/// 应用本地加密数据库(Drift + SQLCipher AES-256)。 +/// +/// 脚手架简化版(schemaVersion = 1): +/// - 只包含 UsersTable 和 ErrorLogsTable +/// - 无历史迁移路径,初始 v1 schema 即为全量 schema +/// +/// 启动序列: +/// 1. 注册 SQLCipher 原生库(主 Isolate + 后台 Isolate 各自注册) +/// 2. 拿到加密密钥(来自 [DbKeyProvider],详见 T-0.5) +/// 3. 探测现有文件是否可用当前密钥打开;失败则删除重建 +/// 4. NativeDatabase setup 时执行 `PRAGMA key = '...'` 解锁 +/// +/// 详见 docs/flutter-architecture-design.md §九.1.1 + §十一.5。 +@DriftDatabase(tables: [Users, ErrorLogs]) +class AppDatabase extends _$AppDatabase { + AppDatabase._(super.e); + + /// 测试专用:内存 SQLite(不走 SQLCipher,用于纯 schema/逻辑测试)。 + /// + /// 生产路径必须用 [open](SQLCipher AES-256)。 + @visibleForTesting + factory AppDatabase.testInMemory() => AppDatabase._(NativeDatabase.memory()); + + static Future open({DbKeyProvider? keyProvider}) async { + // 必须在主 Isolate 执行(内部用 MethodChannel):确保 libsqlcipher.so 已通过 + // Java System.loadLibrary 加载到进程内存,后台 Isolate 的 dlopen 才能找到它。 + if (!kIsWeb && Platform.isAndroid) { + await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions(); + } + + // 主 Isolate 也注册 SQLCipher override,供下方探测检查使用。 + // 各 Isolate 全局状态独立,此处设置不影响后台 Isolate(由 isolateSetup 负责)。 + if (!kIsWeb) _sqlCipherIsolateSetup(); + + final dir = await getApplicationDocumentsDirectory(); + final file = File(p.join(dir.path, 'app.db')); + final key = await (keyProvider ?? DbKeyProvider()).key; + final escaped = key.replaceAll("'", "''"); + + // 探测:在后台 Isolate 启动前,用当前密钥尝试打开现有文件。 + // 捕获所有不可恢复情况: + // - plain SQLite 遗留文件(未加密,PRAGMA key 被忽略) + // - SQLCipher 文件但密钥不匹配(SecureStorage 重置等场景) + // - 文件损坏 + // 检测失败直接删除 —— 开发阶段文件无不可恢复的用户数据。 + if (!kIsWeb && file.existsSync()) { + if (!_canOpenWithKey(file.path, escaped)) { + file.deleteSync(); + } + } + + return AppDatabase._( + NativeDatabase.createInBackground( + file, + // 顶层函数引用(见 _sqlCipherIsolateSetup 注释) + isolateSetup: _sqlCipherIsolateSetup, + setup: (db) { + // R-SEC-1 / 2026-05-10:PRAGMA key 字符串格式 — 必须做 SQL 单引号转义, + // 防止未来策略升级(PBKDF2 / 服务端下发)后 key 含 ' 字符触发注入。 + // + // 注意:不切换到 SQLCipher 推荐的 raw key HEX 格式(x'...'),原因: + // 该格式要求恰好 64 个 hex 字符(32 字节 raw key),现有 RandomKeyStrategy + // 产出的 base64Url(32 bytes) ≈ 43 字符不符合,切换会破坏已加密 DB 的兼容。 + // 完整迁移到 raw key 需独立设计(含数据迁移路径),见 readiness checklist。 + db.execute("PRAGMA key = '$escaped';"); + }, + ), + ); + } + + /// 用当前密钥同步探测文件是否可正常读取。 + /// + /// 在主 Isolate 执行(open() 在 runApp 之前 await),不阻塞 UI。 + /// 主 Isolate 已通过 [_sqlCipherIsolateSetup] 注册 SQLCipher, + /// 所以 sqlite3.open 走的是 SQLCipher,与后台 Isolate 行为一致。 + static bool _canOpenWithKey(String path, String escapedKey) { + try { + final probe = sqlite3_pkg.sqlite3.open(path); + try { + probe + ..execute("PRAGMA key = '$escapedKey';") + ..select('PRAGMA user_version;'); + return true; + } finally { + probe.dispose(); + } + } on Exception catch (_) { + return false; + } + } + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (m) => m.createAll(), + ); +} + +/// 全局 AppDatabase provider。在 main.dart 通过 override 注入实例。 +@Riverpod(keepAlive: true) +AppDatabase appDatabase(Ref ref) => throw UnimplementedError( + 'appDatabaseProvider must be overridden in main.dart', +); diff --git a/lib/core/storage/app_database.g.dart b/lib/core/storage/app_database.g.dart new file mode 100644 index 0000000..6424a7e --- /dev/null +++ b/lib/core/storage/app_database.g.dart @@ -0,0 +1,2546 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_database.dart'; + +// ignore_for_file: type=lint +class $UsersTable extends Users with TableInfo<$UsersTable, UserRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $UsersTable(this.attachedDatabase, [this._alias]); + @override + late final GeneratedColumnWithTypeConverter syncStatus = + GeneratedColumn( + 'sync_status', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ).withConverter($UsersTable.$convertersyncStatus); + static const VerificationMeta _localUpdatedAtMeta = const VerificationMeta( + 'localUpdatedAt', + ); + @override + late final GeneratedColumn localUpdatedAt = + GeneratedColumn( + 'local_updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _serverUpdatedAtMeta = const VerificationMeta( + 'serverUpdatedAt', + ); + @override + late final GeneratedColumn serverUpdatedAt = + GeneratedColumn( + 'server_updated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _conflictPayloadMeta = const VerificationMeta( + 'conflictPayload', + ); + @override + late final GeneratedColumn conflictPayload = GeneratedColumn( + 'conflict_payload', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); + @override + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _usernameMeta = const VerificationMeta( + 'username', + ); + @override + late final GeneratedColumn username = GeneratedColumn( + 'username', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _realNameMeta = const VerificationMeta( + 'realName', + ); + @override + late final GeneratedColumn realName = GeneratedColumn( + 'real_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _phoneMeta = const VerificationMeta('phone'); + @override + late final GeneratedColumn phone = GeneratedColumn( + 'phone', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _avatarMeta = const VerificationMeta('avatar'); + @override + late final GeneratedColumn avatar = GeneratedColumn( + 'avatar', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _genderMeta = const VerificationMeta('gender'); + @override + late final GeneratedColumn gender = GeneratedColumn( + 'gender', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _ageMeta = const VerificationMeta('age'); + @override + late final GeneratedColumn age = GeneratedColumn( + 'age', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _refreshTokenMeta = const VerificationMeta( + 'refreshToken', + ); + @override + late final GeneratedColumn refreshToken = GeneratedColumn( + 'refresh_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _emailMeta = const VerificationMeta('email'); + @override + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _birthdayMeta = const VerificationMeta( + 'birthday', + ); + @override + late final GeneratedColumn birthday = GeneratedColumn( + 'birthday', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _employeeNoMeta = const VerificationMeta( + 'employeeNo', + ); + @override + late final GeneratedColumn employeeNo = GeneratedColumn( + 'employee_no', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _companyMeta = const VerificationMeta( + 'company', + ); + @override + late final GeneratedColumn company = GeneratedColumn( + 'company', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _departmentMeta = const VerificationMeta( + 'department', + ); + @override + late final GeneratedColumn department = GeneratedColumn( + 'department', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + syncStatus, + localUpdatedAt, + serverUpdatedAt, + conflictPayload, + userId, + username, + realName, + phone, + avatar, + gender, + age, + refreshToken, + email, + birthday, + employeeNo, + company, + department, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'users'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('local_updated_at')) { + context.handle( + _localUpdatedAtMeta, + localUpdatedAt.isAcceptableOrUnknown( + data['local_updated_at']!, + _localUpdatedAtMeta, + ), + ); + } + if (data.containsKey('server_updated_at')) { + context.handle( + _serverUpdatedAtMeta, + serverUpdatedAt.isAcceptableOrUnknown( + data['server_updated_at']!, + _serverUpdatedAtMeta, + ), + ); + } + if (data.containsKey('conflict_payload')) { + context.handle( + _conflictPayloadMeta, + conflictPayload.isAcceptableOrUnknown( + data['conflict_payload']!, + _conflictPayloadMeta, + ), + ); + } + if (data.containsKey('user_id')) { + context.handle( + _userIdMeta, + userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta), + ); + } else if (isInserting) { + context.missing(_userIdMeta); + } + if (data.containsKey('username')) { + context.handle( + _usernameMeta, + username.isAcceptableOrUnknown(data['username']!, _usernameMeta), + ); + } + if (data.containsKey('real_name')) { + context.handle( + _realNameMeta, + realName.isAcceptableOrUnknown(data['real_name']!, _realNameMeta), + ); + } + if (data.containsKey('phone')) { + context.handle( + _phoneMeta, + phone.isAcceptableOrUnknown(data['phone']!, _phoneMeta), + ); + } + if (data.containsKey('avatar')) { + context.handle( + _avatarMeta, + avatar.isAcceptableOrUnknown(data['avatar']!, _avatarMeta), + ); + } + if (data.containsKey('gender')) { + context.handle( + _genderMeta, + gender.isAcceptableOrUnknown(data['gender']!, _genderMeta), + ); + } + if (data.containsKey('age')) { + context.handle( + _ageMeta, + age.isAcceptableOrUnknown(data['age']!, _ageMeta), + ); + } + if (data.containsKey('refresh_token')) { + context.handle( + _refreshTokenMeta, + refreshToken.isAcceptableOrUnknown( + data['refresh_token']!, + _refreshTokenMeta, + ), + ); + } + if (data.containsKey('email')) { + context.handle( + _emailMeta, + email.isAcceptableOrUnknown(data['email']!, _emailMeta), + ); + } + if (data.containsKey('birthday')) { + context.handle( + _birthdayMeta, + birthday.isAcceptableOrUnknown(data['birthday']!, _birthdayMeta), + ); + } + if (data.containsKey('employee_no')) { + context.handle( + _employeeNoMeta, + employeeNo.isAcceptableOrUnknown(data['employee_no']!, _employeeNoMeta), + ); + } + if (data.containsKey('company')) { + context.handle( + _companyMeta, + company.isAcceptableOrUnknown(data['company']!, _companyMeta), + ); + } + if (data.containsKey('department')) { + context.handle( + _departmentMeta, + department.isAcceptableOrUnknown(data['department']!, _departmentMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {userId}; + @override + UserRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserRow( + syncStatus: $UsersTable.$convertersyncStatus.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sync_status'], + )!, + ), + localUpdatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}local_updated_at'], + )!, + serverUpdatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}server_updated_at'], + ), + conflictPayload: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}conflict_payload'], + ), + userId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_id'], + )!, + username: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}username'], + ), + realName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}real_name'], + ), + phone: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}phone'], + ), + avatar: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}avatar'], + ), + gender: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}gender'], + ), + age: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}age'], + ), + refreshToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}refresh_token'], + ), + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + ), + birthday: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}birthday'], + ), + employeeNo: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}employee_no'], + ), + company: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}company'], + ), + department: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}department'], + ), + ); + } + + @override + $UsersTable createAlias(String alias) { + return $UsersTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 $convertersyncStatus = + const EnumIndexConverter(SyncStatus.values); +} + +class UserRow extends DataClass implements Insertable { + final SyncStatus syncStatus; + final DateTime localUpdatedAt; + final DateTime? serverUpdatedAt; + final String? conflictPayload; + final String userId; + final String? username; + final String? realName; + final String? phone; + final String? avatar; + final int? gender; + final int? age; + final String? refreshToken; + final String? email; + final DateTime? birthday; + final String? employeeNo; + final String? company; + final String? department; + const UserRow({ + required this.syncStatus, + required this.localUpdatedAt, + this.serverUpdatedAt, + this.conflictPayload, + required this.userId, + this.username, + this.realName, + this.phone, + this.avatar, + this.gender, + this.age, + this.refreshToken, + this.email, + this.birthday, + this.employeeNo, + this.company, + this.department, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + { + map['sync_status'] = Variable( + $UsersTable.$convertersyncStatus.toSql(syncStatus), + ); + } + map['local_updated_at'] = Variable(localUpdatedAt); + if (!nullToAbsent || serverUpdatedAt != null) { + map['server_updated_at'] = Variable(serverUpdatedAt); + } + if (!nullToAbsent || conflictPayload != null) { + map['conflict_payload'] = Variable(conflictPayload); + } + map['user_id'] = Variable(userId); + if (!nullToAbsent || username != null) { + map['username'] = Variable(username); + } + if (!nullToAbsent || realName != null) { + map['real_name'] = Variable(realName); + } + if (!nullToAbsent || phone != null) { + map['phone'] = Variable(phone); + } + if (!nullToAbsent || avatar != null) { + map['avatar'] = Variable(avatar); + } + if (!nullToAbsent || gender != null) { + map['gender'] = Variable(gender); + } + if (!nullToAbsent || age != null) { + map['age'] = Variable(age); + } + if (!nullToAbsent || refreshToken != null) { + map['refresh_token'] = Variable(refreshToken); + } + if (!nullToAbsent || email != null) { + map['email'] = Variable(email); + } + if (!nullToAbsent || birthday != null) { + map['birthday'] = Variable(birthday); + } + if (!nullToAbsent || employeeNo != null) { + map['employee_no'] = Variable(employeeNo); + } + if (!nullToAbsent || company != null) { + map['company'] = Variable(company); + } + if (!nullToAbsent || department != null) { + map['department'] = Variable(department); + } + return map; + } + + UsersCompanion toCompanion(bool nullToAbsent) { + return UsersCompanion( + syncStatus: Value(syncStatus), + localUpdatedAt: Value(localUpdatedAt), + serverUpdatedAt: serverUpdatedAt == null && nullToAbsent + ? const Value.absent() + : Value(serverUpdatedAt), + conflictPayload: conflictPayload == null && nullToAbsent + ? const Value.absent() + : Value(conflictPayload), + userId: Value(userId), + username: username == null && nullToAbsent + ? const Value.absent() + : Value(username), + realName: realName == null && nullToAbsent + ? const Value.absent() + : Value(realName), + phone: phone == null && nullToAbsent + ? const Value.absent() + : Value(phone), + avatar: avatar == null && nullToAbsent + ? const Value.absent() + : Value(avatar), + gender: gender == null && nullToAbsent + ? const Value.absent() + : Value(gender), + age: age == null && nullToAbsent ? const Value.absent() : Value(age), + refreshToken: refreshToken == null && nullToAbsent + ? const Value.absent() + : Value(refreshToken), + email: email == null && nullToAbsent + ? const Value.absent() + : Value(email), + birthday: birthday == null && nullToAbsent + ? const Value.absent() + : Value(birthday), + employeeNo: employeeNo == null && nullToAbsent + ? const Value.absent() + : Value(employeeNo), + company: company == null && nullToAbsent + ? const Value.absent() + : Value(company), + department: department == null && nullToAbsent + ? const Value.absent() + : Value(department), + ); + } + + factory UserRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserRow( + syncStatus: $UsersTable.$convertersyncStatus.fromJson( + serializer.fromJson(json['syncStatus']), + ), + localUpdatedAt: serializer.fromJson(json['localUpdatedAt']), + serverUpdatedAt: serializer.fromJson(json['serverUpdatedAt']), + conflictPayload: serializer.fromJson(json['conflictPayload']), + userId: serializer.fromJson(json['userId']), + username: serializer.fromJson(json['username']), + realName: serializer.fromJson(json['realName']), + phone: serializer.fromJson(json['phone']), + avatar: serializer.fromJson(json['avatar']), + gender: serializer.fromJson(json['gender']), + age: serializer.fromJson(json['age']), + refreshToken: serializer.fromJson(json['refreshToken']), + email: serializer.fromJson(json['email']), + birthday: serializer.fromJson(json['birthday']), + employeeNo: serializer.fromJson(json['employeeNo']), + company: serializer.fromJson(json['company']), + department: serializer.fromJson(json['department']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'syncStatus': serializer.toJson( + $UsersTable.$convertersyncStatus.toJson(syncStatus), + ), + 'localUpdatedAt': serializer.toJson(localUpdatedAt), + 'serverUpdatedAt': serializer.toJson(serverUpdatedAt), + 'conflictPayload': serializer.toJson(conflictPayload), + 'userId': serializer.toJson(userId), + 'username': serializer.toJson(username), + 'realName': serializer.toJson(realName), + 'phone': serializer.toJson(phone), + 'avatar': serializer.toJson(avatar), + 'gender': serializer.toJson(gender), + 'age': serializer.toJson(age), + 'refreshToken': serializer.toJson(refreshToken), + 'email': serializer.toJson(email), + 'birthday': serializer.toJson(birthday), + 'employeeNo': serializer.toJson(employeeNo), + 'company': serializer.toJson(company), + 'department': serializer.toJson(department), + }; + } + + UserRow copyWith({ + SyncStatus? syncStatus, + DateTime? localUpdatedAt, + Value serverUpdatedAt = const Value.absent(), + Value conflictPayload = const Value.absent(), + String? userId, + Value username = const Value.absent(), + Value realName = const Value.absent(), + Value phone = const Value.absent(), + Value avatar = const Value.absent(), + Value gender = const Value.absent(), + Value age = const Value.absent(), + Value refreshToken = const Value.absent(), + Value email = const Value.absent(), + Value birthday = const Value.absent(), + Value employeeNo = const Value.absent(), + Value company = const Value.absent(), + Value department = const Value.absent(), + }) => UserRow( + syncStatus: syncStatus ?? this.syncStatus, + localUpdatedAt: localUpdatedAt ?? this.localUpdatedAt, + serverUpdatedAt: serverUpdatedAt.present + ? serverUpdatedAt.value + : this.serverUpdatedAt, + conflictPayload: conflictPayload.present + ? conflictPayload.value + : this.conflictPayload, + userId: userId ?? this.userId, + username: username.present ? username.value : this.username, + realName: realName.present ? realName.value : this.realName, + phone: phone.present ? phone.value : this.phone, + avatar: avatar.present ? avatar.value : this.avatar, + gender: gender.present ? gender.value : this.gender, + age: age.present ? age.value : this.age, + refreshToken: refreshToken.present ? refreshToken.value : this.refreshToken, + email: email.present ? email.value : this.email, + birthday: birthday.present ? birthday.value : this.birthday, + employeeNo: employeeNo.present ? employeeNo.value : this.employeeNo, + company: company.present ? company.value : this.company, + department: department.present ? department.value : this.department, + ); + UserRow copyWithCompanion(UsersCompanion data) { + return UserRow( + syncStatus: data.syncStatus.present + ? data.syncStatus.value + : this.syncStatus, + localUpdatedAt: data.localUpdatedAt.present + ? data.localUpdatedAt.value + : this.localUpdatedAt, + serverUpdatedAt: data.serverUpdatedAt.present + ? data.serverUpdatedAt.value + : this.serverUpdatedAt, + conflictPayload: data.conflictPayload.present + ? data.conflictPayload.value + : this.conflictPayload, + userId: data.userId.present ? data.userId.value : this.userId, + username: data.username.present ? data.username.value : this.username, + realName: data.realName.present ? data.realName.value : this.realName, + phone: data.phone.present ? data.phone.value : this.phone, + avatar: data.avatar.present ? data.avatar.value : this.avatar, + gender: data.gender.present ? data.gender.value : this.gender, + age: data.age.present ? data.age.value : this.age, + refreshToken: data.refreshToken.present + ? data.refreshToken.value + : this.refreshToken, + email: data.email.present ? data.email.value : this.email, + birthday: data.birthday.present ? data.birthday.value : this.birthday, + employeeNo: data.employeeNo.present + ? data.employeeNo.value + : this.employeeNo, + company: data.company.present ? data.company.value : this.company, + department: data.department.present + ? data.department.value + : this.department, + ); + } + + @override + String toString() { + return (StringBuffer('UserRow(') + ..write('syncStatus: $syncStatus, ') + ..write('localUpdatedAt: $localUpdatedAt, ') + ..write('serverUpdatedAt: $serverUpdatedAt, ') + ..write('conflictPayload: $conflictPayload, ') + ..write('userId: $userId, ') + ..write('username: $username, ') + ..write('realName: $realName, ') + ..write('phone: $phone, ') + ..write('avatar: $avatar, ') + ..write('gender: $gender, ') + ..write('age: $age, ') + ..write('refreshToken: $refreshToken, ') + ..write('email: $email, ') + ..write('birthday: $birthday, ') + ..write('employeeNo: $employeeNo, ') + ..write('company: $company, ') + ..write('department: $department') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + syncStatus, + localUpdatedAt, + serverUpdatedAt, + conflictPayload, + userId, + username, + realName, + phone, + avatar, + gender, + age, + refreshToken, + email, + birthday, + employeeNo, + company, + department, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserRow && + other.syncStatus == this.syncStatus && + other.localUpdatedAt == this.localUpdatedAt && + other.serverUpdatedAt == this.serverUpdatedAt && + other.conflictPayload == this.conflictPayload && + other.userId == this.userId && + other.username == this.username && + other.realName == this.realName && + other.phone == this.phone && + other.avatar == this.avatar && + other.gender == this.gender && + other.age == this.age && + other.refreshToken == this.refreshToken && + other.email == this.email && + other.birthday == this.birthday && + other.employeeNo == this.employeeNo && + other.company == this.company && + other.department == this.department); +} + +class UsersCompanion extends UpdateCompanion { + final Value syncStatus; + final Value localUpdatedAt; + final Value serverUpdatedAt; + final Value conflictPayload; + final Value userId; + final Value username; + final Value realName; + final Value phone; + final Value avatar; + final Value gender; + final Value age; + final Value refreshToken; + final Value email; + final Value birthday; + final Value employeeNo; + final Value company; + final Value department; + final Value rowid; + const UsersCompanion({ + this.syncStatus = const Value.absent(), + this.localUpdatedAt = const Value.absent(), + this.serverUpdatedAt = const Value.absent(), + this.conflictPayload = const Value.absent(), + this.userId = const Value.absent(), + this.username = const Value.absent(), + this.realName = const Value.absent(), + this.phone = const Value.absent(), + this.avatar = const Value.absent(), + this.gender = const Value.absent(), + this.age = const Value.absent(), + this.refreshToken = const Value.absent(), + this.email = const Value.absent(), + this.birthday = const Value.absent(), + this.employeeNo = const Value.absent(), + this.company = const Value.absent(), + this.department = const Value.absent(), + this.rowid = const Value.absent(), + }); + UsersCompanion.insert({ + this.syncStatus = const Value.absent(), + this.localUpdatedAt = const Value.absent(), + this.serverUpdatedAt = const Value.absent(), + this.conflictPayload = const Value.absent(), + required String userId, + this.username = const Value.absent(), + this.realName = const Value.absent(), + this.phone = const Value.absent(), + this.avatar = const Value.absent(), + this.gender = const Value.absent(), + this.age = const Value.absent(), + this.refreshToken = const Value.absent(), + this.email = const Value.absent(), + this.birthday = const Value.absent(), + this.employeeNo = const Value.absent(), + this.company = const Value.absent(), + this.department = const Value.absent(), + this.rowid = const Value.absent(), + }) : userId = Value(userId); + static Insertable custom({ + Expression? syncStatus, + Expression? localUpdatedAt, + Expression? serverUpdatedAt, + Expression? conflictPayload, + Expression? userId, + Expression? username, + Expression? realName, + Expression? phone, + Expression? avatar, + Expression? gender, + Expression? age, + Expression? refreshToken, + Expression? email, + Expression? birthday, + Expression? employeeNo, + Expression? company, + Expression? department, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (syncStatus != null) 'sync_status': syncStatus, + if (localUpdatedAt != null) 'local_updated_at': localUpdatedAt, + if (serverUpdatedAt != null) 'server_updated_at': serverUpdatedAt, + if (conflictPayload != null) 'conflict_payload': conflictPayload, + if (userId != null) 'user_id': userId, + if (username != null) 'username': username, + if (realName != null) 'real_name': realName, + if (phone != null) 'phone': phone, + if (avatar != null) 'avatar': avatar, + if (gender != null) 'gender': gender, + if (age != null) 'age': age, + if (refreshToken != null) 'refresh_token': refreshToken, + if (email != null) 'email': email, + if (birthday != null) 'birthday': birthday, + if (employeeNo != null) 'employee_no': employeeNo, + if (company != null) 'company': company, + if (department != null) 'department': department, + if (rowid != null) 'rowid': rowid, + }); + } + + UsersCompanion copyWith({ + Value? syncStatus, + Value? localUpdatedAt, + Value? serverUpdatedAt, + Value? conflictPayload, + Value? userId, + Value? username, + Value? realName, + Value? phone, + Value? avatar, + Value? gender, + Value? age, + Value? refreshToken, + Value? email, + Value? birthday, + Value? employeeNo, + Value? company, + Value? department, + Value? rowid, + }) { + return UsersCompanion( + syncStatus: syncStatus ?? this.syncStatus, + localUpdatedAt: localUpdatedAt ?? this.localUpdatedAt, + serverUpdatedAt: serverUpdatedAt ?? this.serverUpdatedAt, + conflictPayload: conflictPayload ?? this.conflictPayload, + userId: userId ?? this.userId, + username: username ?? this.username, + realName: realName ?? this.realName, + phone: phone ?? this.phone, + avatar: avatar ?? this.avatar, + gender: gender ?? this.gender, + age: age ?? this.age, + refreshToken: refreshToken ?? this.refreshToken, + email: email ?? this.email, + birthday: birthday ?? this.birthday, + employeeNo: employeeNo ?? this.employeeNo, + company: company ?? this.company, + department: department ?? this.department, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (syncStatus.present) { + map['sync_status'] = Variable( + $UsersTable.$convertersyncStatus.toSql(syncStatus.value), + ); + } + if (localUpdatedAt.present) { + map['local_updated_at'] = Variable(localUpdatedAt.value); + } + if (serverUpdatedAt.present) { + map['server_updated_at'] = Variable(serverUpdatedAt.value); + } + if (conflictPayload.present) { + map['conflict_payload'] = Variable(conflictPayload.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (username.present) { + map['username'] = Variable(username.value); + } + if (realName.present) { + map['real_name'] = Variable(realName.value); + } + if (phone.present) { + map['phone'] = Variable(phone.value); + } + if (avatar.present) { + map['avatar'] = Variable(avatar.value); + } + if (gender.present) { + map['gender'] = Variable(gender.value); + } + if (age.present) { + map['age'] = Variable(age.value); + } + if (refreshToken.present) { + map['refresh_token'] = Variable(refreshToken.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (birthday.present) { + map['birthday'] = Variable(birthday.value); + } + if (employeeNo.present) { + map['employee_no'] = Variable(employeeNo.value); + } + if (company.present) { + map['company'] = Variable(company.value); + } + if (department.present) { + map['department'] = Variable(department.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UsersCompanion(') + ..write('syncStatus: $syncStatus, ') + ..write('localUpdatedAt: $localUpdatedAt, ') + ..write('serverUpdatedAt: $serverUpdatedAt, ') + ..write('conflictPayload: $conflictPayload, ') + ..write('userId: $userId, ') + ..write('username: $username, ') + ..write('realName: $realName, ') + ..write('phone: $phone, ') + ..write('avatar: $avatar, ') + ..write('gender: $gender, ') + ..write('age: $age, ') + ..write('refreshToken: $refreshToken, ') + ..write('email: $email, ') + ..write('birthday: $birthday, ') + ..write('employeeNo: $employeeNo, ') + ..write('company: $company, ') + ..write('department: $department, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ErrorLogsTable extends ErrorLogs + with TableInfo<$ErrorLogsTable, ErrorLogRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ErrorLogsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _kindMeta = const VerificationMeta('kind'); + @override + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _messageMeta = const VerificationMeta( + 'message', + ); + @override + late final GeneratedColumn message = GeneratedColumn( + 'message', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _displayMessageMeta = const VerificationMeta( + 'displayMessage', + ); + @override + late final GeneratedColumn displayMessage = GeneratedColumn( + 'display_message', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _codeMeta = const VerificationMeta('code'); + @override + late final GeneratedColumn code = GeneratedColumn( + 'code', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _statusCodeMeta = const VerificationMeta( + 'statusCode', + ); + @override + late final GeneratedColumn statusCode = GeneratedColumn( + 'status_code', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _occurredAtMeta = const VerificationMeta( + 'occurredAt', + ); + @override + late final GeneratedColumn occurredAt = GeneratedColumn( + 'occurred_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _reportedMeta = const VerificationMeta( + 'reported', + ); + @override + late final GeneratedColumn reported = GeneratedColumn( + 'reported', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("reported" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _deviceModelMeta = const VerificationMeta( + 'deviceModel', + ); + @override + late final GeneratedColumn deviceModel = GeneratedColumn( + 'device_model', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _osVersionMeta = const VerificationMeta( + 'osVersion', + ); + @override + late final GeneratedColumn osVersion = GeneratedColumn( + 'os_version', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _appVersionMeta = const VerificationMeta( + 'appVersion', + ); + @override + late final GeneratedColumn appVersion = GeneratedColumn( + 'app_version', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _appBuildMeta = const VerificationMeta( + 'appBuild', + ); + @override + late final GeneratedColumn appBuild = GeneratedColumn( + 'app_build', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + kind, + message, + displayMessage, + code, + statusCode, + occurredAt, + reported, + deviceModel, + osVersion, + appVersion, + appBuild, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'error_logs'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('kind')) { + context.handle( + _kindMeta, + kind.isAcceptableOrUnknown(data['kind']!, _kindMeta), + ); + } else if (isInserting) { + context.missing(_kindMeta); + } + if (data.containsKey('message')) { + context.handle( + _messageMeta, + message.isAcceptableOrUnknown(data['message']!, _messageMeta), + ); + } else if (isInserting) { + context.missing(_messageMeta); + } + if (data.containsKey('display_message')) { + context.handle( + _displayMessageMeta, + displayMessage.isAcceptableOrUnknown( + data['display_message']!, + _displayMessageMeta, + ), + ); + } else if (isInserting) { + context.missing(_displayMessageMeta); + } + if (data.containsKey('code')) { + context.handle( + _codeMeta, + code.isAcceptableOrUnknown(data['code']!, _codeMeta), + ); + } + if (data.containsKey('status_code')) { + context.handle( + _statusCodeMeta, + statusCode.isAcceptableOrUnknown(data['status_code']!, _statusCodeMeta), + ); + } + if (data.containsKey('occurred_at')) { + context.handle( + _occurredAtMeta, + occurredAt.isAcceptableOrUnknown(data['occurred_at']!, _occurredAtMeta), + ); + } else if (isInserting) { + context.missing(_occurredAtMeta); + } + if (data.containsKey('reported')) { + context.handle( + _reportedMeta, + reported.isAcceptableOrUnknown(data['reported']!, _reportedMeta), + ); + } + if (data.containsKey('device_model')) { + context.handle( + _deviceModelMeta, + deviceModel.isAcceptableOrUnknown( + data['device_model']!, + _deviceModelMeta, + ), + ); + } + if (data.containsKey('os_version')) { + context.handle( + _osVersionMeta, + osVersion.isAcceptableOrUnknown(data['os_version']!, _osVersionMeta), + ); + } + if (data.containsKey('app_version')) { + context.handle( + _appVersionMeta, + appVersion.isAcceptableOrUnknown(data['app_version']!, _appVersionMeta), + ); + } + if (data.containsKey('app_build')) { + context.handle( + _appBuildMeta, + appBuild.isAcceptableOrUnknown(data['app_build']!, _appBuildMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ErrorLogRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ErrorLogRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + kind: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}kind'], + )!, + message: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}message'], + )!, + displayMessage: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}display_message'], + )!, + code: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}code'], + ), + statusCode: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}status_code'], + ), + occurredAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}occurred_at'], + )!, + reported: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}reported'], + )!, + deviceModel: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}device_model'], + ), + osVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}os_version'], + ), + appVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}app_version'], + ), + appBuild: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}app_build'], + ), + ); + } + + @override + $ErrorLogsTable createAlias(String alias) { + return $ErrorLogsTable(attachedDatabase, alias); + } +} + +class ErrorLogRow extends DataClass implements Insertable { + final int id; + final String kind; + final String message; + final String displayMessage; + final String? code; + final int? statusCode; + final DateTime occurredAt; + final bool reported; + final String? deviceModel; + final String? osVersion; + final String? appVersion; + final String? appBuild; + const ErrorLogRow({ + required this.id, + required this.kind, + required this.message, + required this.displayMessage, + this.code, + this.statusCode, + required this.occurredAt, + required this.reported, + this.deviceModel, + this.osVersion, + this.appVersion, + this.appBuild, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['kind'] = Variable(kind); + map['message'] = Variable(message); + map['display_message'] = Variable(displayMessage); + if (!nullToAbsent || code != null) { + map['code'] = Variable(code); + } + if (!nullToAbsent || statusCode != null) { + map['status_code'] = Variable(statusCode); + } + map['occurred_at'] = Variable(occurredAt); + map['reported'] = Variable(reported); + if (!nullToAbsent || deviceModel != null) { + map['device_model'] = Variable(deviceModel); + } + if (!nullToAbsent || osVersion != null) { + map['os_version'] = Variable(osVersion); + } + if (!nullToAbsent || appVersion != null) { + map['app_version'] = Variable(appVersion); + } + if (!nullToAbsent || appBuild != null) { + map['app_build'] = Variable(appBuild); + } + return map; + } + + ErrorLogsCompanion toCompanion(bool nullToAbsent) { + return ErrorLogsCompanion( + id: Value(id), + kind: Value(kind), + message: Value(message), + displayMessage: Value(displayMessage), + code: code == null && nullToAbsent ? const Value.absent() : Value(code), + statusCode: statusCode == null && nullToAbsent + ? const Value.absent() + : Value(statusCode), + occurredAt: Value(occurredAt), + reported: Value(reported), + deviceModel: deviceModel == null && nullToAbsent + ? const Value.absent() + : Value(deviceModel), + osVersion: osVersion == null && nullToAbsent + ? const Value.absent() + : Value(osVersion), + appVersion: appVersion == null && nullToAbsent + ? const Value.absent() + : Value(appVersion), + appBuild: appBuild == null && nullToAbsent + ? const Value.absent() + : Value(appBuild), + ); + } + + factory ErrorLogRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ErrorLogRow( + id: serializer.fromJson(json['id']), + kind: serializer.fromJson(json['kind']), + message: serializer.fromJson(json['message']), + displayMessage: serializer.fromJson(json['displayMessage']), + code: serializer.fromJson(json['code']), + statusCode: serializer.fromJson(json['statusCode']), + occurredAt: serializer.fromJson(json['occurredAt']), + reported: serializer.fromJson(json['reported']), + deviceModel: serializer.fromJson(json['deviceModel']), + osVersion: serializer.fromJson(json['osVersion']), + appVersion: serializer.fromJson(json['appVersion']), + appBuild: serializer.fromJson(json['appBuild']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'kind': serializer.toJson(kind), + 'message': serializer.toJson(message), + 'displayMessage': serializer.toJson(displayMessage), + 'code': serializer.toJson(code), + 'statusCode': serializer.toJson(statusCode), + 'occurredAt': serializer.toJson(occurredAt), + 'reported': serializer.toJson(reported), + 'deviceModel': serializer.toJson(deviceModel), + 'osVersion': serializer.toJson(osVersion), + 'appVersion': serializer.toJson(appVersion), + 'appBuild': serializer.toJson(appBuild), + }; + } + + ErrorLogRow copyWith({ + int? id, + String? kind, + String? message, + String? displayMessage, + Value code = const Value.absent(), + Value statusCode = const Value.absent(), + DateTime? occurredAt, + bool? reported, + Value deviceModel = const Value.absent(), + Value osVersion = const Value.absent(), + Value appVersion = const Value.absent(), + Value appBuild = const Value.absent(), + }) => ErrorLogRow( + id: id ?? this.id, + kind: kind ?? this.kind, + message: message ?? this.message, + displayMessage: displayMessage ?? this.displayMessage, + code: code.present ? code.value : this.code, + statusCode: statusCode.present ? statusCode.value : this.statusCode, + occurredAt: occurredAt ?? this.occurredAt, + reported: reported ?? this.reported, + deviceModel: deviceModel.present ? deviceModel.value : this.deviceModel, + osVersion: osVersion.present ? osVersion.value : this.osVersion, + appVersion: appVersion.present ? appVersion.value : this.appVersion, + appBuild: appBuild.present ? appBuild.value : this.appBuild, + ); + ErrorLogRow copyWithCompanion(ErrorLogsCompanion data) { + return ErrorLogRow( + id: data.id.present ? data.id.value : this.id, + kind: data.kind.present ? data.kind.value : this.kind, + message: data.message.present ? data.message.value : this.message, + displayMessage: data.displayMessage.present + ? data.displayMessage.value + : this.displayMessage, + code: data.code.present ? data.code.value : this.code, + statusCode: data.statusCode.present + ? data.statusCode.value + : this.statusCode, + occurredAt: data.occurredAt.present + ? data.occurredAt.value + : this.occurredAt, + reported: data.reported.present ? data.reported.value : this.reported, + deviceModel: data.deviceModel.present + ? data.deviceModel.value + : this.deviceModel, + osVersion: data.osVersion.present ? data.osVersion.value : this.osVersion, + appVersion: data.appVersion.present + ? data.appVersion.value + : this.appVersion, + appBuild: data.appBuild.present ? data.appBuild.value : this.appBuild, + ); + } + + @override + String toString() { + return (StringBuffer('ErrorLogRow(') + ..write('id: $id, ') + ..write('kind: $kind, ') + ..write('message: $message, ') + ..write('displayMessage: $displayMessage, ') + ..write('code: $code, ') + ..write('statusCode: $statusCode, ') + ..write('occurredAt: $occurredAt, ') + ..write('reported: $reported, ') + ..write('deviceModel: $deviceModel, ') + ..write('osVersion: $osVersion, ') + ..write('appVersion: $appVersion, ') + ..write('appBuild: $appBuild') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + kind, + message, + displayMessage, + code, + statusCode, + occurredAt, + reported, + deviceModel, + osVersion, + appVersion, + appBuild, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ErrorLogRow && + other.id == this.id && + other.kind == this.kind && + other.message == this.message && + other.displayMessage == this.displayMessage && + other.code == this.code && + other.statusCode == this.statusCode && + other.occurredAt == this.occurredAt && + other.reported == this.reported && + other.deviceModel == this.deviceModel && + other.osVersion == this.osVersion && + other.appVersion == this.appVersion && + other.appBuild == this.appBuild); +} + +class ErrorLogsCompanion extends UpdateCompanion { + final Value id; + final Value kind; + final Value message; + final Value displayMessage; + final Value code; + final Value statusCode; + final Value occurredAt; + final Value reported; + final Value deviceModel; + final Value osVersion; + final Value appVersion; + final Value appBuild; + const ErrorLogsCompanion({ + this.id = const Value.absent(), + this.kind = const Value.absent(), + this.message = const Value.absent(), + this.displayMessage = const Value.absent(), + this.code = const Value.absent(), + this.statusCode = const Value.absent(), + this.occurredAt = const Value.absent(), + this.reported = const Value.absent(), + this.deviceModel = const Value.absent(), + this.osVersion = const Value.absent(), + this.appVersion = const Value.absent(), + this.appBuild = const Value.absent(), + }); + ErrorLogsCompanion.insert({ + this.id = const Value.absent(), + required String kind, + required String message, + required String displayMessage, + this.code = const Value.absent(), + this.statusCode = const Value.absent(), + required DateTime occurredAt, + this.reported = const Value.absent(), + this.deviceModel = const Value.absent(), + this.osVersion = const Value.absent(), + this.appVersion = const Value.absent(), + this.appBuild = const Value.absent(), + }) : kind = Value(kind), + message = Value(message), + displayMessage = Value(displayMessage), + occurredAt = Value(occurredAt); + static Insertable custom({ + Expression? id, + Expression? kind, + Expression? message, + Expression? displayMessage, + Expression? code, + Expression? statusCode, + Expression? occurredAt, + Expression? reported, + Expression? deviceModel, + Expression? osVersion, + Expression? appVersion, + Expression? appBuild, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (kind != null) 'kind': kind, + if (message != null) 'message': message, + if (displayMessage != null) 'display_message': displayMessage, + if (code != null) 'code': code, + if (statusCode != null) 'status_code': statusCode, + if (occurredAt != null) 'occurred_at': occurredAt, + if (reported != null) 'reported': reported, + if (deviceModel != null) 'device_model': deviceModel, + if (osVersion != null) 'os_version': osVersion, + if (appVersion != null) 'app_version': appVersion, + if (appBuild != null) 'app_build': appBuild, + }); + } + + ErrorLogsCompanion copyWith({ + Value? id, + Value? kind, + Value? message, + Value? displayMessage, + Value? code, + Value? statusCode, + Value? occurredAt, + Value? reported, + Value? deviceModel, + Value? osVersion, + Value? appVersion, + Value? appBuild, + }) { + return ErrorLogsCompanion( + id: id ?? this.id, + kind: kind ?? this.kind, + message: message ?? this.message, + displayMessage: displayMessage ?? this.displayMessage, + code: code ?? this.code, + statusCode: statusCode ?? this.statusCode, + occurredAt: occurredAt ?? this.occurredAt, + reported: reported ?? this.reported, + deviceModel: deviceModel ?? this.deviceModel, + osVersion: osVersion ?? this.osVersion, + appVersion: appVersion ?? this.appVersion, + appBuild: appBuild ?? this.appBuild, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (kind.present) { + map['kind'] = Variable(kind.value); + } + if (message.present) { + map['message'] = Variable(message.value); + } + if (displayMessage.present) { + map['display_message'] = Variable(displayMessage.value); + } + if (code.present) { + map['code'] = Variable(code.value); + } + if (statusCode.present) { + map['status_code'] = Variable(statusCode.value); + } + if (occurredAt.present) { + map['occurred_at'] = Variable(occurredAt.value); + } + if (reported.present) { + map['reported'] = Variable(reported.value); + } + if (deviceModel.present) { + map['device_model'] = Variable(deviceModel.value); + } + if (osVersion.present) { + map['os_version'] = Variable(osVersion.value); + } + if (appVersion.present) { + map['app_version'] = Variable(appVersion.value); + } + if (appBuild.present) { + map['app_build'] = Variable(appBuild.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ErrorLogsCompanion(') + ..write('id: $id, ') + ..write('kind: $kind, ') + ..write('message: $message, ') + ..write('displayMessage: $displayMessage, ') + ..write('code: $code, ') + ..write('statusCode: $statusCode, ') + ..write('occurredAt: $occurredAt, ') + ..write('reported: $reported, ') + ..write('deviceModel: $deviceModel, ') + ..write('osVersion: $osVersion, ') + ..write('appVersion: $appVersion, ') + ..write('appBuild: $appBuild') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $UsersTable users = $UsersTable(this); + late final $ErrorLogsTable errorLogs = $ErrorLogsTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [users, errorLogs]; +} + +typedef $$UsersTableCreateCompanionBuilder = + UsersCompanion Function({ + Value syncStatus, + Value localUpdatedAt, + Value serverUpdatedAt, + Value conflictPayload, + required String userId, + Value username, + Value realName, + Value phone, + Value avatar, + Value gender, + Value age, + Value refreshToken, + Value email, + Value birthday, + Value employeeNo, + Value company, + Value department, + Value rowid, + }); +typedef $$UsersTableUpdateCompanionBuilder = + UsersCompanion Function({ + Value syncStatus, + Value localUpdatedAt, + Value serverUpdatedAt, + Value conflictPayload, + Value userId, + Value username, + Value realName, + Value phone, + Value avatar, + Value gender, + Value age, + Value refreshToken, + Value email, + Value birthday, + Value employeeNo, + Value company, + Value department, + Value rowid, + }); + +class $$UsersTableFilterComposer extends Composer<_$AppDatabase, $UsersTable> { + $$UsersTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnWithTypeConverterFilters get syncStatus => + $composableBuilder( + column: $table.syncStatus, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get localUpdatedAt => $composableBuilder( + column: $table.localUpdatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverUpdatedAt => $composableBuilder( + column: $table.serverUpdatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get conflictPayload => $composableBuilder( + column: $table.conflictPayload, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get userId => $composableBuilder( + column: $table.userId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get username => $composableBuilder( + column: $table.username, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get realName => $composableBuilder( + column: $table.realName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get phone => $composableBuilder( + column: $table.phone, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get avatar => $composableBuilder( + column: $table.avatar, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get gender => $composableBuilder( + column: $table.gender, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get age => $composableBuilder( + column: $table.age, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get refreshToken => $composableBuilder( + column: $table.refreshToken, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get birthday => $composableBuilder( + column: $table.birthday, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get employeeNo => $composableBuilder( + column: $table.employeeNo, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get company => $composableBuilder( + column: $table.company, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get department => $composableBuilder( + column: $table.department, + builder: (column) => ColumnFilters(column), + ); +} + +class $$UsersTableOrderingComposer + extends Composer<_$AppDatabase, $UsersTable> { + $$UsersTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get syncStatus => $composableBuilder( + column: $table.syncStatus, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get localUpdatedAt => $composableBuilder( + column: $table.localUpdatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverUpdatedAt => $composableBuilder( + column: $table.serverUpdatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get conflictPayload => $composableBuilder( + column: $table.conflictPayload, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get userId => $composableBuilder( + column: $table.userId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get username => $composableBuilder( + column: $table.username, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get realName => $composableBuilder( + column: $table.realName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get phone => $composableBuilder( + column: $table.phone, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get avatar => $composableBuilder( + column: $table.avatar, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get gender => $composableBuilder( + column: $table.gender, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get age => $composableBuilder( + column: $table.age, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get refreshToken => $composableBuilder( + column: $table.refreshToken, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get birthday => $composableBuilder( + column: $table.birthday, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get employeeNo => $composableBuilder( + column: $table.employeeNo, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get company => $composableBuilder( + column: $table.company, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get department => $composableBuilder( + column: $table.department, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$UsersTableAnnotationComposer + extends Composer<_$AppDatabase, $UsersTable> { + $$UsersTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumnWithTypeConverter get syncStatus => + $composableBuilder( + column: $table.syncStatus, + builder: (column) => column, + ); + + GeneratedColumn get localUpdatedAt => $composableBuilder( + column: $table.localUpdatedAt, + builder: (column) => column, + ); + + GeneratedColumn get serverUpdatedAt => $composableBuilder( + column: $table.serverUpdatedAt, + builder: (column) => column, + ); + + GeneratedColumn get conflictPayload => $composableBuilder( + column: $table.conflictPayload, + builder: (column) => column, + ); + + GeneratedColumn get userId => + $composableBuilder(column: $table.userId, builder: (column) => column); + + GeneratedColumn get username => + $composableBuilder(column: $table.username, builder: (column) => column); + + GeneratedColumn get realName => + $composableBuilder(column: $table.realName, builder: (column) => column); + + GeneratedColumn get phone => + $composableBuilder(column: $table.phone, builder: (column) => column); + + GeneratedColumn get avatar => + $composableBuilder(column: $table.avatar, builder: (column) => column); + + GeneratedColumn get gender => + $composableBuilder(column: $table.gender, builder: (column) => column); + + GeneratedColumn get age => + $composableBuilder(column: $table.age, builder: (column) => column); + + GeneratedColumn get refreshToken => $composableBuilder( + column: $table.refreshToken, + builder: (column) => column, + ); + + GeneratedColumn get email => + $composableBuilder(column: $table.email, builder: (column) => column); + + GeneratedColumn get birthday => + $composableBuilder(column: $table.birthday, builder: (column) => column); + + GeneratedColumn get employeeNo => $composableBuilder( + column: $table.employeeNo, + builder: (column) => column, + ); + + GeneratedColumn get company => + $composableBuilder(column: $table.company, builder: (column) => column); + + GeneratedColumn get department => $composableBuilder( + column: $table.department, + builder: (column) => column, + ); +} + +class $$UsersTableTableManager + extends + RootTableManager< + _$AppDatabase, + $UsersTable, + UserRow, + $$UsersTableFilterComposer, + $$UsersTableOrderingComposer, + $$UsersTableAnnotationComposer, + $$UsersTableCreateCompanionBuilder, + $$UsersTableUpdateCompanionBuilder, + (UserRow, BaseReferences<_$AppDatabase, $UsersTable, UserRow>), + UserRow, + PrefetchHooks Function() + > { + $$UsersTableTableManager(_$AppDatabase db, $UsersTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$UsersTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$UsersTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$UsersTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value syncStatus = const Value.absent(), + Value localUpdatedAt = const Value.absent(), + Value serverUpdatedAt = const Value.absent(), + Value conflictPayload = const Value.absent(), + Value userId = const Value.absent(), + Value username = const Value.absent(), + Value realName = const Value.absent(), + Value phone = const Value.absent(), + Value avatar = const Value.absent(), + Value gender = const Value.absent(), + Value age = const Value.absent(), + Value refreshToken = const Value.absent(), + Value email = const Value.absent(), + Value birthday = const Value.absent(), + Value employeeNo = const Value.absent(), + Value company = const Value.absent(), + Value department = const Value.absent(), + Value rowid = const Value.absent(), + }) => UsersCompanion( + syncStatus: syncStatus, + localUpdatedAt: localUpdatedAt, + serverUpdatedAt: serverUpdatedAt, + conflictPayload: conflictPayload, + userId: userId, + username: username, + realName: realName, + phone: phone, + avatar: avatar, + gender: gender, + age: age, + refreshToken: refreshToken, + email: email, + birthday: birthday, + employeeNo: employeeNo, + company: company, + department: department, + rowid: rowid, + ), + createCompanionCallback: + ({ + Value syncStatus = const Value.absent(), + Value localUpdatedAt = const Value.absent(), + Value serverUpdatedAt = const Value.absent(), + Value conflictPayload = const Value.absent(), + required String userId, + Value username = const Value.absent(), + Value realName = const Value.absent(), + Value phone = const Value.absent(), + Value avatar = const Value.absent(), + Value gender = const Value.absent(), + Value age = const Value.absent(), + Value refreshToken = const Value.absent(), + Value email = const Value.absent(), + Value birthday = const Value.absent(), + Value employeeNo = const Value.absent(), + Value company = const Value.absent(), + Value department = const Value.absent(), + Value rowid = const Value.absent(), + }) => UsersCompanion.insert( + syncStatus: syncStatus, + localUpdatedAt: localUpdatedAt, + serverUpdatedAt: serverUpdatedAt, + conflictPayload: conflictPayload, + userId: userId, + username: username, + realName: realName, + phone: phone, + avatar: avatar, + gender: gender, + age: age, + refreshToken: refreshToken, + email: email, + birthday: birthday, + employeeNo: employeeNo, + company: company, + department: department, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$UsersTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $UsersTable, + UserRow, + $$UsersTableFilterComposer, + $$UsersTableOrderingComposer, + $$UsersTableAnnotationComposer, + $$UsersTableCreateCompanionBuilder, + $$UsersTableUpdateCompanionBuilder, + (UserRow, BaseReferences<_$AppDatabase, $UsersTable, UserRow>), + UserRow, + PrefetchHooks Function() + >; +typedef $$ErrorLogsTableCreateCompanionBuilder = + ErrorLogsCompanion Function({ + Value id, + required String kind, + required String message, + required String displayMessage, + Value code, + Value statusCode, + required DateTime occurredAt, + Value reported, + Value deviceModel, + Value osVersion, + Value appVersion, + Value appBuild, + }); +typedef $$ErrorLogsTableUpdateCompanionBuilder = + ErrorLogsCompanion Function({ + Value id, + Value kind, + Value message, + Value displayMessage, + Value code, + Value statusCode, + Value occurredAt, + Value reported, + Value deviceModel, + Value osVersion, + Value appVersion, + Value appBuild, + }); + +class $$ErrorLogsTableFilterComposer + extends Composer<_$AppDatabase, $ErrorLogsTable> { + $$ErrorLogsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get kind => $composableBuilder( + column: $table.kind, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get message => $composableBuilder( + column: $table.message, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get displayMessage => $composableBuilder( + column: $table.displayMessage, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get code => $composableBuilder( + column: $table.code, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get statusCode => $composableBuilder( + column: $table.statusCode, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get occurredAt => $composableBuilder( + column: $table.occurredAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get reported => $composableBuilder( + column: $table.reported, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deviceModel => $composableBuilder( + column: $table.deviceModel, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get osVersion => $composableBuilder( + column: $table.osVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get appVersion => $composableBuilder( + column: $table.appVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get appBuild => $composableBuilder( + column: $table.appBuild, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ErrorLogsTableOrderingComposer + extends Composer<_$AppDatabase, $ErrorLogsTable> { + $$ErrorLogsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get kind => $composableBuilder( + column: $table.kind, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get message => $composableBuilder( + column: $table.message, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get displayMessage => $composableBuilder( + column: $table.displayMessage, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get code => $composableBuilder( + column: $table.code, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get statusCode => $composableBuilder( + column: $table.statusCode, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get occurredAt => $composableBuilder( + column: $table.occurredAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get reported => $composableBuilder( + column: $table.reported, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deviceModel => $composableBuilder( + column: $table.deviceModel, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get osVersion => $composableBuilder( + column: $table.osVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get appVersion => $composableBuilder( + column: $table.appVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get appBuild => $composableBuilder( + column: $table.appBuild, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ErrorLogsTableAnnotationComposer + extends Composer<_$AppDatabase, $ErrorLogsTable> { + $$ErrorLogsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get kind => + $composableBuilder(column: $table.kind, builder: (column) => column); + + GeneratedColumn get message => + $composableBuilder(column: $table.message, builder: (column) => column); + + GeneratedColumn get displayMessage => $composableBuilder( + column: $table.displayMessage, + builder: (column) => column, + ); + + GeneratedColumn get code => + $composableBuilder(column: $table.code, builder: (column) => column); + + GeneratedColumn get statusCode => $composableBuilder( + column: $table.statusCode, + builder: (column) => column, + ); + + GeneratedColumn get occurredAt => $composableBuilder( + column: $table.occurredAt, + builder: (column) => column, + ); + + GeneratedColumn get reported => + $composableBuilder(column: $table.reported, builder: (column) => column); + + GeneratedColumn get deviceModel => $composableBuilder( + column: $table.deviceModel, + builder: (column) => column, + ); + + GeneratedColumn get osVersion => + $composableBuilder(column: $table.osVersion, builder: (column) => column); + + GeneratedColumn get appVersion => $composableBuilder( + column: $table.appVersion, + builder: (column) => column, + ); + + GeneratedColumn get appBuild => + $composableBuilder(column: $table.appBuild, builder: (column) => column); +} + +class $$ErrorLogsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ErrorLogsTable, + ErrorLogRow, + $$ErrorLogsTableFilterComposer, + $$ErrorLogsTableOrderingComposer, + $$ErrorLogsTableAnnotationComposer, + $$ErrorLogsTableCreateCompanionBuilder, + $$ErrorLogsTableUpdateCompanionBuilder, + ( + ErrorLogRow, + BaseReferences<_$AppDatabase, $ErrorLogsTable, ErrorLogRow>, + ), + ErrorLogRow, + PrefetchHooks Function() + > { + $$ErrorLogsTableTableManager(_$AppDatabase db, $ErrorLogsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ErrorLogsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ErrorLogsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ErrorLogsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value kind = const Value.absent(), + Value message = const Value.absent(), + Value displayMessage = const Value.absent(), + Value code = const Value.absent(), + Value statusCode = const Value.absent(), + Value occurredAt = const Value.absent(), + Value reported = const Value.absent(), + Value deviceModel = const Value.absent(), + Value osVersion = const Value.absent(), + Value appVersion = const Value.absent(), + Value appBuild = const Value.absent(), + }) => ErrorLogsCompanion( + id: id, + kind: kind, + message: message, + displayMessage: displayMessage, + code: code, + statusCode: statusCode, + occurredAt: occurredAt, + reported: reported, + deviceModel: deviceModel, + osVersion: osVersion, + appVersion: appVersion, + appBuild: appBuild, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String kind, + required String message, + required String displayMessage, + Value code = const Value.absent(), + Value statusCode = const Value.absent(), + required DateTime occurredAt, + Value reported = const Value.absent(), + Value deviceModel = const Value.absent(), + Value osVersion = const Value.absent(), + Value appVersion = const Value.absent(), + Value appBuild = const Value.absent(), + }) => ErrorLogsCompanion.insert( + id: id, + kind: kind, + message: message, + displayMessage: displayMessage, + code: code, + statusCode: statusCode, + occurredAt: occurredAt, + reported: reported, + deviceModel: deviceModel, + osVersion: osVersion, + appVersion: appVersion, + appBuild: appBuild, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ErrorLogsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ErrorLogsTable, + ErrorLogRow, + $$ErrorLogsTableFilterComposer, + $$ErrorLogsTableOrderingComposer, + $$ErrorLogsTableAnnotationComposer, + $$ErrorLogsTableCreateCompanionBuilder, + $$ErrorLogsTableUpdateCompanionBuilder, + ( + ErrorLogRow, + BaseReferences<_$AppDatabase, $ErrorLogsTable, ErrorLogRow>, + ), + ErrorLogRow, + PrefetchHooks Function() + >; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$UsersTableTableManager get users => + $$UsersTableTableManager(_db, _db.users); + $$ErrorLogsTableTableManager get errorLogs => + $$ErrorLogsTableTableManager(_db, _db.errorLogs); +} + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// 全局 AppDatabase provider。在 main.dart 通过 override 注入实例。 + +@ProviderFor(appDatabase) +final appDatabaseProvider = AppDatabaseProvider._(); + +/// 全局 AppDatabase provider。在 main.dart 通过 override 注入实例。 + +final class AppDatabaseProvider + extends $FunctionalProvider + with $Provider { + /// 全局 AppDatabase provider。在 main.dart 通过 override 注入实例。 + AppDatabaseProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'appDatabaseProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$appDatabaseHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + AppDatabase create(Ref ref) { + return appDatabase(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AppDatabase value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$appDatabaseHash() => r'67944daf71969f5618c4fa9cdd60a2d1db056dd5'; diff --git a/lib/core/storage/daos/users_dao.dart b/lib/core/storage/daos/users_dao.dart new file mode 100644 index 0000000..03f8ceb --- /dev/null +++ b/lib/core/storage/daos/users_dao.dart @@ -0,0 +1,28 @@ +import 'package:drift/drift.dart'; + +import 'package:sunny_mochi/core/storage/app_database.dart'; +import 'package:sunny_mochi/core/storage/tables/users_table.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'users_dao.g.dart'; + +@DriftAccessor(tables: [Users]) +class UsersDao extends DatabaseAccessor with _$UsersDaoMixin { + UsersDao(super.attachedDatabase); + + Future getById(String userId) => + (select(users)..where((u) => u.userId.equals(userId))).getSingleOrNull(); + + Stream watchById(String userId) => (select( + users, + )..where((u) => u.userId.equals(userId))).watchSingleOrNull(); + + Future upsert(UsersCompanion user) => + into(users).insertOnConflictUpdate(user); + + Future deleteById(String userId) => + (delete(users)..where((u) => u.userId.equals(userId))).go(); +} + +@Riverpod(keepAlive: true) +UsersDao usersDao(Ref ref) => UsersDao(ref.watch(appDatabaseProvider)); diff --git a/lib/core/storage/daos/users_dao.g.dart b/lib/core/storage/daos/users_dao.g.dart new file mode 100644 index 0000000..719ca16 --- /dev/null +++ b/lib/core/storage/daos/users_dao.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'users_dao.dart'; + +// ignore_for_file: type=lint +mixin _$UsersDaoMixin on DatabaseAccessor { + $UsersTable get users => attachedDatabase.users; + UsersDaoManager get managers => UsersDaoManager(this); +} + +class UsersDaoManager { + final _$UsersDaoMixin _db; + UsersDaoManager(this._db); + $$UsersTableTableManager get users => + $$UsersTableTableManager(_db.attachedDatabase, _db.users); +} + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(usersDao) +final usersDaoProvider = UsersDaoProvider._(); + +final class UsersDaoProvider + extends $FunctionalProvider + with $Provider { + UsersDaoProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'usersDaoProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$usersDaoHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + UsersDao create(Ref ref) { + return usersDao(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(UsersDao value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$usersDaoHash() => r'209c5286cb20f72750a77a007a5c04c7ef22bfea'; diff --git a/lib/core/storage/db_key_provider.dart b/lib/core/storage/db_key_provider.dart new file mode 100644 index 0000000..9601302 --- /dev/null +++ b/lib/core/storage/db_key_provider.dart @@ -0,0 +1,60 @@ +// ignore_for_file: one_member_abstracts — 是策略接口(PBKDF2 / 服务端下发等替换实现 P3 落地) + +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// SQLCipher 数据库主密钥派生策略。 +/// +/// 当前实现:`RandomKeyStrategy` — 首次启动随机生成 32 字节, +/// 通过 [FlutterSecureStorage](iOS Keychain / Android EncryptedSharedPreferences)持久化。 +/// 用户无感知,体验最佳;用户换设备会丢失本地数据(云端备份不影响)。 +/// +/// 备选策略(待 Q7 答复决议,仅替换 [DbKeyProvider.strategy] 即可,调用方零改动): +/// - 用户密码派生(PBKDF2):强合规,改密复杂 +/// - 服务端下发:可主动撤销,离线不可解锁 +/// - 两段式(A+B 组合):最复杂 +/// +/// 详见 docs/flutter-architecture-design.md §九.1.2 与 §十一.5。 +abstract class KeyDerivationStrategy { + Future deriveKey(); +} + +class RandomKeyStrategy implements KeyDerivationStrategy { + RandomKeyStrategy({FlutterSecureStorage? storage}) + : _storage = storage ?? _defaultStorage; + + static const _storageKey = 'db_master_key_v1'; + static const FlutterSecureStorage _defaultStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + final FlutterSecureStorage _storage; + + @override + Future deriveKey() async { + final existing = await _storage.read(key: _storageKey); + if (existing != null && existing.isNotEmpty) return existing; + + final rng = Random.secure(); + final bytes = List.generate(32, (_) => rng.nextInt(256)); + final key = base64Url.encode(bytes); + await _storage.write(key: _storageKey, value: key); + return key; + } +} + +class DbKeyProvider { + DbKeyProvider({KeyDerivationStrategy? strategy}) + : _strategy = strategy ?? RandomKeyStrategy(); + + final KeyDerivationStrategy _strategy; + + String? _cached; + + Future get key async { + return _cached ??= await _strategy.deriveKey(); + } +} diff --git a/lib/core/storage/secure_storage.dart b/lib/core/storage/secure_storage.dart new file mode 100644 index 0000000..a4b8b97 --- /dev/null +++ b/lib/core/storage/secure_storage.dart @@ -0,0 +1,59 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Token / 用户标识等敏感数据的本地持久化(对应 iOS UserManager.shared.token)。 +/// +/// **DI 注入(R-TEST-1 / 2026-05-10)**:构造函数接受可选 [FlutterSecureStorage], +/// 测试时可注入 in-memory 替代实现,避开真实 Keychain / EncryptedSharedPreferences。 +/// 生产代码通过 [secureStorageProvider] 获取实例,**禁止再使用 [SecureStorage.instance]**。 +class SecureStorage { + SecureStorage({FlutterSecureStorage? storage}) + : _storage = storage ?? _defaultStorage; + + /// 兼容旧调用点的静态实例(与 provider 默认值保持一致)。 + /// 新代码应通过 [secureStorageProvider] 注入。 + static final instance = SecureStorage(); + + static const FlutterSecureStorage _defaultStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + // 与 db_key_provider.dart 一致:解锁后台可读,避免锁屏后 token 不可用 + accessibility: KeychainAccessibility.first_unlock, + ), + ); + + final FlutterSecureStorage _storage; + + static const _keyToken = 'auth_token'; + /// sa-token 动态 header 名(对齐 iOS UserManager.tokenNameKey)。 + static const _keyTokenName = 'auth_token_name'; + static const _keyRefreshToken = 'refresh_token'; + static const _keyUserId = 'user_id'; + + Future getToken() => _storage.read(key: _keyToken); + Future setToken(String token) => + _storage.write(key: _keyToken, value: token); + + /// sa-token 动态 header 名(如 `satoken`)— 必须从登录响应取,不能硬编码。 + Future getTokenName() => _storage.read(key: _keyTokenName); + Future setTokenName(String name) => + _storage.write(key: _keyTokenName, value: name); + + Future getRefreshToken() => _storage.read(key: _keyRefreshToken); + Future setRefreshToken(String token) => + _storage.write(key: _keyRefreshToken, value: token); + + Future getUserId() => _storage.read(key: _keyUserId); + Future setUserId(String id) => + _storage.write(key: _keyUserId, value: id); + + Future clearAll() => _storage.deleteAll(); +} + +/// SecureStorage 全局 provider — storage DI 的单一定义点。 +/// +/// 构造注入规则(R-ARCH-2):所有需要访问安全存储的类(拦截器/Repository/Provider) +/// 必须通过此 provider 获取,禁止直接引用 [SecureStorage.instance] 静态单例。 +final secureStorageProvider = Provider( + (ref) => SecureStorage.instance, +); diff --git a/lib/core/storage/tables/error_logs_table.dart b/lib/core/storage/tables/error_logs_table.dart new file mode 100644 index 0000000..52838ef --- /dev/null +++ b/lib/core/storage/tables/error_logs_table.dart @@ -0,0 +1,31 @@ +import 'package:drift/drift.dart'; + +/// 本地错误日志表(keep last 200)。 +/// +/// [kind] — 错误分类(ErrorKind.name),前端展示用 +/// [message] — 技术细节,供后端追踪(不展示给用户) +/// [displayMessage] — 用户看到的友好文案(Failure.userMessage) +/// [code] — 业务错误码(ServerFailure.code,如 A0500) +/// [statusCode] — HTTP 状态码(ServerFailure.statusCode) +/// [reported] — 是否已通过"错误报告"功能上报过 +/// +/// 设备信息(v5 新增,nullable 兼容迁移): +/// [deviceModel] / [osVersion] / [appVersion] / [appBuild] +/// 在 log() 时快照,确保上报的是**错误发生时**的设备上下文,而非发送时。 +@DataClassName('ErrorLogRow') +class ErrorLogs extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get kind => text()(); + TextColumn get message => text()(); + TextColumn get displayMessage => text()(); + TextColumn get code => text().nullable()(); + IntColumn get statusCode => integer().nullable()(); + DateTimeColumn get occurredAt => dateTime()(); + BoolColumn get reported => + boolean().withDefault(const Constant(false))(); + // v5: 设备上下文(事发时快照) + TextColumn get deviceModel => text().nullable()(); + TextColumn get osVersion => text().nullable()(); + TextColumn get appVersion => text().nullable()(); + TextColumn get appBuild => text().nullable()(); +} diff --git a/lib/core/storage/tables/sync_columns.dart b/lib/core/storage/tables/sync_columns.dart new file mode 100644 index 0000000..2afea9a --- /dev/null +++ b/lib/core/storage/tables/sync_columns.dart @@ -0,0 +1,20 @@ +import 'package:drift/drift.dart'; + +import 'package:sunny_mochi/core/sync/sync_status.dart'; + +/// 所有需要 Offline-First 同步的 Drift 表必须 mixin 这组 4 字段。 +/// +/// - [syncStatus] 当前状态([SyncStatus]) +/// - [localUpdatedAt] 本地最后修改时间(用于决定 dirty) +/// - [serverUpdatedAt] 服务端最后修改时间(last-write-wins 比较基准) +/// - [conflictPayload] 冲突时备份的服务端版本 JSON(人工或策略恢复) +/// +/// 详见 docs/flutter-architecture-design.md §五.21。 +mixin SyncColumns on Table { + IntColumn get syncStatus => + intEnum().withDefault(const Constant(0))(); + DateTimeColumn get localUpdatedAt => + dateTime().withDefault(currentDateAndTime)(); + DateTimeColumn get serverUpdatedAt => dateTime().nullable()(); + TextColumn get conflictPayload => text().nullable()(); +} diff --git a/lib/core/storage/tables/users_table.dart b/lib/core/storage/tables/users_table.dart new file mode 100644 index 0000000..ad7d90b --- /dev/null +++ b/lib/core/storage/tables/users_table.dart @@ -0,0 +1,26 @@ +import 'package:drift/drift.dart'; +import 'package:sunny_mochi/core/storage/tables/sync_columns.dart'; + +/// 用户本地缓存表(对应 iOS UserManager.currentUser 持久化)。 +/// +/// token 不存 DB — 由 SecureStorage 独占(Keychain / EncryptedSharedPreferences); +/// refreshToken 镜像存入 DB 仅用于可观测性,TokenRefreshInterceptor 仍读 SecureStorage。 +@DataClassName('UserRow') +class Users extends Table with SyncColumns { + TextColumn get userId => text()(); + TextColumn get username => text().nullable()(); + TextColumn get realName => text().nullable()(); + TextColumn get phone => text().nullable()(); + TextColumn get avatar => text().nullable()(); + IntColumn get gender => integer().nullable()(); + IntColumn get age => integer().nullable()(); + TextColumn get refreshToken => text().nullable()(); + TextColumn get email => text().nullable()(); + DateTimeColumn get birthday => dateTime().nullable()(); + TextColumn get employeeNo => text().nullable()(); + TextColumn get company => text().nullable()(); + TextColumn get department => text().nullable()(); + + @override + Set> get primaryKey => {userId}; +} diff --git a/lib/core/sync/conflict_resolver.dart b/lib/core/sync/conflict_resolver.dart new file mode 100644 index 0000000..8cd3cfc --- /dev/null +++ b/lib/core/sync/conflict_resolver.dart @@ -0,0 +1,38 @@ +// ignore_for_file: one_member_abstracts — Strategy 接口,多种实现(ServerWins/LastWriteWins/Custom) + +/// 冲突解决策略:服务端响应与本地版本不一致时如何决策。 +/// +/// v1.2 默认策略:`LastWriteWinsResolver`(按 serverUpdatedAt 比较时间戳)。 +/// 业务场景需要乐观锁 / 三方合并的,单独实现 `ConflictResolver` 子类。 +abstract class ConflictResolver { + /// 返回解决后的最终值(由调用方写回 DB)。 + T resolve({required T local, required T server}); +} + +/// 默认实现:服务端权威 — 本地直接被服务端覆盖。 +/// 适合健康数据采集这种"服务端是 source-of-truth"的场景。 +class ServerWinsResolver implements ConflictResolver { + const ServerWinsResolver(); + + @override + T resolve({required T local, required T server}) => server; +} + +/// last-write-wins by timestamp。 +/// 调用方传入 timestamp 比较函数(avoid 包装 generic)。 +class LastWriteWinsResolver implements ConflictResolver { + const LastWriteWinsResolver({ + required this.localTimestamp, + required this.serverTimestamp, + }); + + final DateTime Function(T value) localTimestamp; + final DateTime Function(T value) serverTimestamp; + + @override + T resolve({required T local, required T server}) { + return serverTimestamp(server).isAfter(localTimestamp(local)) + ? server + : local; + } +} diff --git a/lib/core/sync/sync_service.dart b/lib/core/sync/sync_service.dart new file mode 100644 index 0000000..5d23c08 --- /dev/null +++ b/lib/core/sync/sync_service.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:sunny_mochi/core/observability/talker_setup.dart'; +import 'package:sunny_mochi/core/sync/sync_status.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'sync_service.g.dart'; + +/// 单条 dirty 记录的同步执行器。各业务模块实现此接口注册到 SyncService。 +abstract class SyncTask { + /// 唯一标识(用于日志和去重) + String get key; + + /// 执行同步:成功返回 true,失败返回 false(保留 pending 等下轮)。 + /// 冲突时由实现方写入 conflictPayload 并返回 false。 + Future execute(); +} + +/// Offline-First 后台同步调度器。 +/// +/// - 监听 [Connectivity] 网络状态变化,恢复联网时触发一轮同步 +/// - 业务层通过 [enqueue] 注册 SyncTask +/// - 串行执行(避免并发污染服务端) +/// +/// 详见 docs/flutter-architecture-design.md §五.21。 +class SyncService { + SyncService({ + required Connectivity connectivity, + }) : _connectivity = connectivity; + + final Connectivity _connectivity; + final List _queue = []; + + StreamSubscription>? _connSub; + bool _running = false; + + /// 启动后台监听。在 main.dart 完成 ProviderScope 初始化后调用。 + /// + /// **R-ROB-1 / 2026-05-10**:幂等 — 重复调用会先 cancel 旧 subscription, + /// 防止 hot reload / Widget 重建时累积订阅造成 _drain 多次触发。 + Future start() async { + await _connSub?.cancel(); + _connSub = _connectivity.onConnectivityChanged.listen(_onConnectivity); + final initial = await _connectivity.checkConnectivity(); + if (_isOnline(initial)) { + unawaited(_drain()); + } + } + + Future dispose() async { + await _connSub?.cancel(); + _connSub = null; + } + + /// 业务层注册一个 dirty 任务(同步后由任务自身从队列移除)。 + void enqueue(SyncTask task) { + _queue.add(task); + appTalker.verbose('[Sync] enqueue ${task.key} (queue=${_queue.length})'); + unawaited(_drain()); + } + + Future _drain() async { + if (_running || _queue.isEmpty) return; + _running = true; + try { + while (_queue.isNotEmpty) { + final task = _queue.removeAt(0); + try { + final ok = await task.execute(); + appTalker.info( + '[Sync] ${task.key} ${ok ? "✓" : "✗ (status=${SyncStatus.conflict.name} 待解决)"}', + ); + } on Object catch (e, st) { + appTalker.warning('[Sync] ${task.key} 异常:$e', e, st); + // 失败任务不重新入队(保持 dirty 在 DB 等下轮启动) + } + } + } finally { + _running = false; + } + } + + void _onConnectivity(List results) { + if (_isOnline(results)) { + appTalker.info('[Sync] 检测到联网,触发 drain'); + unawaited(_drain()); + } + } + + bool _isOnline(List results) => + results.any((r) => r != ConnectivityResult.none); +} + +@Riverpod(keepAlive: true) +Connectivity connectivity(Ref ref) => Connectivity(); + +@Riverpod(keepAlive: true) +SyncService syncService(Ref ref) { + final svc = SyncService(connectivity: ref.watch(connectivityProvider)); + ref.onDispose(() => unawaited(svc.dispose())); + return svc; +} diff --git a/lib/core/sync/sync_service.g.dart b/lib/core/sync/sync_service.g.dart new file mode 100644 index 0000000..2f4072a --- /dev/null +++ b/lib/core/sync/sync_service.g.dart @@ -0,0 +1,92 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(connectivity) +final connectivityProvider = ConnectivityProvider._(); + +final class ConnectivityProvider + extends $FunctionalProvider + with $Provider { + ConnectivityProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'connectivityProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$connectivityHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Connectivity create(Ref ref) { + return connectivity(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Connectivity value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$connectivityHash() => r'e66720f09edf1a8b09e450e1eaedd51da9443f0e'; + +@ProviderFor(syncService) +final syncServiceProvider = SyncServiceProvider._(); + +final class SyncServiceProvider + extends $FunctionalProvider + with $Provider { + SyncServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'syncServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$syncServiceHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + SyncService create(Ref ref) { + return syncService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(SyncService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$syncServiceHash() => r'6a8c53aab166422156d95e8cd712592195946e99'; diff --git a/lib/core/sync/sync_status.dart b/lib/core/sync/sync_status.dart new file mode 100644 index 0000000..668de66 --- /dev/null +++ b/lib/core/sync/sync_status.dart @@ -0,0 +1,14 @@ +/// 单条业务记录的同步状态机。Drift 表用 SyncColumns mixin 4 字段约定记录此状态。 +enum SyncStatus { + /// 本地新增/修改,待同步到服务端 + pending, + + /// 正在同步中(避免并发重复发送) + syncing, + + /// 已成功同步到服务端 + synced, + + /// 服务端拒绝/冲突,需要人工或策略解决 + conflict, +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..1a5c00f --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; + +/// 5 套主题色板(white 为 Flutter 新增默认主题,其余 4 套与 iOS plist 1:1 对齐)。 +/// +/// Flutter 默认 `white`(白色导航栏 + 中性灰背景 + 紫色点缀,对应设计稿默认配色)。 +/// iOS 默认 `purple`(AppThemeManager.swift:44 `?? "purple"`)— 两端可独立配置。 +enum AppColorScheme { white, blue, red, green, purple } + +/// 单套主题色板 — 对应一个 plist 文件的 10 个色值 key。 +/// +/// 命名与 iOS ThemeKey 1:1 对应;iOS 用 `view.theme_backgroundColor = ThemeKey.backgroundColor` 绑定, +/// Flutter 通过 [Theme.of(context).extension()] 读取。 +@immutable +class AppPalette extends ThemeExtension { + const AppPalette({ + required this.primary, + required this.secondary, + required this.background, + required this.text, + required this.navBar, + required this.navBarText, + required this.buttonBg, + required this.buttonText, + required this.tabBarSelected, + required this.tabBarNormal, + }); + + final Color primary; + final Color secondary; + final Color background; + final Color text; + final Color navBar; + final Color navBarText; + final Color buttonBg; + final Color buttonText; + final Color tabBarSelected; + final Color tabBarNormal; + + @override + AppPalette copyWith({ + Color? primary, + Color? secondary, + Color? background, + Color? text, + Color? navBar, + Color? navBarText, + Color? buttonBg, + Color? buttonText, + Color? tabBarSelected, + Color? tabBarNormal, + }) { + return AppPalette( + primary: primary ?? this.primary, + secondary: secondary ?? this.secondary, + background: background ?? this.background, + text: text ?? this.text, + navBar: navBar ?? this.navBar, + navBarText: navBarText ?? this.navBarText, + buttonBg: buttonBg ?? this.buttonBg, + buttonText: buttonText ?? this.buttonText, + tabBarSelected: tabBarSelected ?? this.tabBarSelected, + tabBarNormal: tabBarNormal ?? this.tabBarNormal, + ); + } + + @override + AppPalette lerp(ThemeExtension? other, double t) { + if (other is! AppPalette) return this; + return AppPalette( + primary: Color.lerp(primary, other.primary, t)!, + secondary: Color.lerp(secondary, other.secondary, t)!, + background: Color.lerp(background, other.background, t)!, + text: Color.lerp(text, other.text, t)!, + navBar: Color.lerp(navBar, other.navBar, t)!, + navBarText: Color.lerp(navBarText, other.navBarText, t)!, + buttonBg: Color.lerp(buttonBg, other.buttonBg, t)!, + buttonText: Color.lerp(buttonText, other.buttonText, t)!, + tabBarSelected: Color.lerp(tabBarSelected, other.tabBarSelected, t)!, + tabBarNormal: Color.lerp(tabBarNormal, other.tabBarNormal, t)!, + ); + } +} + +/// 5 套色板 — white 为 Flutter 默认主题,其余 4 套与 iOS plist 完全一致。 +abstract class AppPalettes { + /// 白色默认主题:白色导航栏 + 深色文字 + 中性灰背景 + 紫色点缀。 + /// 对应设计稿默认白灰配色(无色调染色)。 + /// + /// 字色 #252535 与设计稿其他地方一致(手机号/列表项/标题统一字色)。 + static const AppPalette white = AppPalette( + primary: Color(0xFF947DFF), + secondary: Color(0xFFA893FF), + background: Color(0xFFF5F6F8), + text: Color(0xFF252535), + navBar: Color(0xFFFFFFFF), + navBarText: Color(0xFF252535), + buttonBg: Color(0xFF947DFF), + buttonText: Color(0xFFFFFFFF), + tabBarSelected: Color(0xFF947DFF), + tabBarNormal: Color(0xFF77849E), + ); + + static const AppPalette blue = AppPalette( + primary: Color(0xFF3366FF), + secondary: Color(0xFF4D7FFF), + background: Color(0xFFF0F4FF), + text: Color(0xFF333333), + navBar: Color(0xFF3366FF), + navBarText: Color(0xFFFFFFFF), + buttonBg: Color(0xFF3366FF), + buttonText: Color(0xFFFFFFFF), + tabBarSelected: Color(0xFF3366FF), + tabBarNormal: Color(0xFF999999), + ); + + static const AppPalette red = AppPalette( + primary: Color(0xFFE63333), + secondary: Color(0xFFFF4D4D), + background: Color(0xFFFFF5F5), + text: Color(0xFF333333), + navBar: Color(0xFFE63333), + navBarText: Color(0xFFFFFFFF), + buttonBg: Color(0xFFE63333), + buttonText: Color(0xFFFFFFFF), + tabBarSelected: Color(0xFFE63333), + tabBarNormal: Color(0xFF999999), + ); + + static const AppPalette green = AppPalette( + primary: Color(0xFF33A855), + secondary: Color(0xFF4DC46A), + background: Color(0xFFF0FFF4), + text: Color(0xFF333333), + navBar: Color(0xFF33A855), + navBarText: Color(0xFFFFFFFF), + buttonBg: Color(0xFF33A855), + buttonText: Color(0xFFFFFFFF), + tabBarSelected: Color(0xFF33A855), + tabBarNormal: Color(0xFF999999), + ); + + static const AppPalette purple = AppPalette( + primary: Color(0xFF947DFF), + secondary: Color(0xFF947DFF), + background: Color(0xFFF8F0FF), + text: Color(0xFF333333), + navBar: Color(0xFF947DFF), + navBarText: Color(0xFFFFFFFF), + buttonBg: Color(0xFF947DFF), + buttonText: Color(0xFFFFFFFF), + tabBarSelected: Color(0xFF947DFF), + tabBarNormal: Color(0xFF999999), + ); + + static const Map all = { + AppColorScheme.white: white, + AppColorScheme.blue: blue, + AppColorScheme.red: red, + AppColorScheme.green: green, + AppColorScheme.purple: purple, + }; + + static AppPalette of(AppColorScheme scheme) => all[scheme]!; + + /// 中文展示名(设置页主题切换 UI 用) + static String displayName(AppColorScheme scheme) => switch (scheme) { + AppColorScheme.white => '默认', + AppColorScheme.blue => '蓝色', + AppColorScheme.red => '红色', + AppColorScheme.green => '绿色', + AppColorScheme.purple => '紫色', + }; +} + +/// 通用色(与主题无关,跨 4 套主题不变) — 文字 / 背景 / 状态色。 +abstract class AppColors { + // 4 套主题色映射(向后兼容旧调用,新代码请用 AppPalettes.of) + static Map get primary => { + for (final e in AppPalettes.all.entries) e.key: e.value.primary, + }; + + // 通用色彩(4 套主题共用) + static const Color surface = Color(0xFFFFFFFF); + static const Color divider = Color(0xFFEEEEEE); + + static const Color textPrimary = Color(0xFF333333); + static const Color textSecondary = Color(0xFF666666); + static const Color textHint = Color(0xFF999999); + static const Color textPlaceholder = Color(0xFFBBBBBB); + + static const Color error = Color(0xFFFF4D4F); + static const Color warning = Color(0xFFFA8C16); + static const Color success = Color(0xFF52C41A); + + // 兼容旧引用(这些常量已退化为 fallback / 跨主题中性色) + static const Color background = Color(0xFFF5F5F5); + static const Color navBar = Color(0xFFFFFFFF); + static const Color navBarDark = Color(0xFF1A1A1A); + static const Color tabBarUnselected = Color(0xFF999999); +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..4f82e06 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:sunny_mochi/core/theme/app_colors.dart'; + +/// 应用主题工厂 — 按 [AppColorScheme] 真实切换全部色(对齐 iOS 4 套 plist)。 +/// +/// 与旧版差异: +/// - 旧版只把 primary 当作 seed,nav/btn/bg 都写死,导致 4 套主题视觉差异不明显 +/// - 新版从 [AppPalettes] 读完整 10 色,AppBar/ColorScheme/extensions 全部按主题切 +/// - 通过 ThemeExtension 暴露 [AppPalette],业务代码 `Theme.of(context).extension()!.buttonBg` 读 +abstract class AppTheme { + static ThemeData light({AppColorScheme scheme = AppColorScheme.white}) { + final palette = AppPalettes.of(scheme); + return ThemeData( + useMaterial3: true, + // BackButton 图标:iOS 平台渲染 arrow_back_ios_new(< 样式),与登录页一致。 + // ScrollBehavior 使用 defaultTargetPlatform,不受此字段影响。 + platform: TargetPlatform.iOS, + // 全局字体 — HarmonyOS Sans SC(GB2312 子集,pubspec 注册) + fontFamily: 'HarmonyOS Sans SC', + colorScheme: ColorScheme.fromSeed( + seedColor: palette.primary, + primary: palette.primary, + secondary: palette.secondary, + surface: AppColors.surface, + error: AppColors.error, + ), + scaffoldBackgroundColor: palette.background, + primaryColor: palette.primary, + appBarTheme: AppBarTheme( + backgroundColor: palette.navBar, + foregroundColor: palette.navBarText, + elevation: 0, + // Material 3 默认会在滚动时给 AppBar 叠加 surfaceTint 染色(紫色); + // 设计稿要求 navBar 保持纯白,必须显式置为 transparent + 0 elevation。 + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + centerTitle: true, + titleTextStyle: TextStyle( + color: palette.navBarText, + fontSize: 18.sp, + fontWeight: FontWeight.w600, + ), + iconTheme: IconThemeData(color: palette.navBarText, size: 22.sp), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: palette.buttonBg, + foregroundColor: palette.buttonText, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: palette.buttonBg, + foregroundColor: palette.buttonText, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: palette.primary), + ), + tabBarTheme: TabBarThemeData( + labelColor: palette.tabBarSelected, + unselectedLabelColor: palette.tabBarNormal, + indicatorColor: palette.tabBarSelected, + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + selectedItemColor: palette.tabBarSelected, + unselectedItemColor: palette.tabBarNormal, + ), + textTheme: TextTheme( + bodyLarge: TextStyle(fontSize: 16.sp, color: palette.text), + bodyMedium: TextStyle(fontSize: 14.sp, color: AppColors.textSecondary), + bodySmall: TextStyle(fontSize: 12.sp, color: AppColors.textHint), + titleLarge: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: palette.text, + ), + ), + dividerColor: AppColors.divider, + dividerTheme: const DividerThemeData(space: 1, thickness: 1), + extensions: [palette], + ); + } + + static ThemeData dark({AppColorScheme scheme = AppColorScheme.white}) { + final palette = AppPalettes.of(scheme); + return ThemeData( + useMaterial3: true, + platform: TargetPlatform.iOS, + brightness: Brightness.dark, + colorScheme: ColorScheme.fromSeed( + seedColor: palette.primary, + primary: palette.primary, + secondary: palette.secondary, + brightness: Brightness.dark, + ), + primaryColor: palette.primary, + scaffoldBackgroundColor: const Color(0xFF121212), + appBarTheme: AppBarTheme( + backgroundColor: AppColors.navBarDark, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: true, + titleTextStyle: TextStyle( + color: Colors.white, + fontSize: 17.sp, + fontWeight: FontWeight.w600, + ), + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + selectedItemColor: palette.tabBarSelected, + unselectedItemColor: palette.tabBarNormal, + ), + extensions: [palette], + ); + } +} diff --git a/lib/core/theme/theme_assets.dart b/lib/core/theme/theme_assets.dart new file mode 100644 index 0000000..408720d --- /dev/null +++ b/lib/core/theme/theme_assets.dart @@ -0,0 +1,32 @@ +import 'package:sunny_mochi/core/theme/app_colors.dart'; + +/// 主题化图片资源分发工具。 +/// +/// 约定:`assets/themes/{scheme}/{name}.png`,每套主题放同名文件。 +/// 调用:`Image.asset(ThemeAssets.tabHomeNormal(scheme))` +/// +/// TODO: 业务项目按需扩展此类,添加与品牌设计对应的主题图片路径。 +/// 若 TabBar 使用 Material Icons(当前脚手架默认),此类可不使用。 +abstract class ThemeAssets { + static const String _root = 'assets/themes'; + + static String _dir(AppColorScheme scheme) => '$_root/${scheme.name}'; + + // ═══ Tab 图标(脚手架默认 2 Tab,业务项目按需扩展)══════════════════════ + + /// Home Tab + static String tabHomeNormal(AppColorScheme scheme) => + '${_dir(scheme)}/tab_home_normal.png'; + static String tabHomeSelected(AppColorScheme scheme) => + '${_dir(scheme)}/tab_home_selected.png'; + + /// Mine Tab + static String tabMineNormal(AppColorScheme scheme) => + '${_dir(scheme)}/tab_mine_normal.png'; + static String tabMineSelected(AppColorScheme scheme) => + '${_dir(scheme)}/tab_mine_selected.png'; + + // ═══ 业务图片占位(TODO: 填入项目实际图片路径)════════════════════════════ + // static String heroBanner(AppColorScheme scheme) => + // '${_dir(scheme)}/hero_banner.png'; +} diff --git a/lib/core/theme/theme_notifier.dart b/lib/core/theme/theme_notifier.dart new file mode 100644 index 0000000..eadbf19 --- /dev/null +++ b/lib/core/theme/theme_notifier.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:sunny_mochi/core/theme/app_colors.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'theme_notifier.g.dart'; + +/// 当前色板 Notifier — 对应 iOS AppThemeManager。 +/// +/// **默认 white**(白色导航栏 + 中性灰背景,对应设计稿默认配色)。 +/// 切换持久化到 SharedPreferences,下次启动恢复。 +@Riverpod(keepAlive: true) +class ThemeNotifier extends _$ThemeNotifier { + static const _keyScheme = 'color_scheme'; + + @override + AppColorScheme build() { + unawaited(_restore()); + return AppColorScheme.white; + } + + /// 切换主题色板(同时持久化) + void switchScheme(AppColorScheme scheme) { + state = scheme; + unawaited( + SharedPreferences.getInstance().then( + (prefs) => prefs.setString(_keyScheme, scheme.name), + ), + ); + } + + Future _restore() async { + final prefs = await SharedPreferences.getInstance(); + final name = prefs.getString(_keyScheme); + if (name != null) { + state = AppColorScheme.values.firstWhere( + (e) => e.name == name, + orElse: () => AppColorScheme.white, + ); + } + } +} + +/// 明亮 / 暗色 / 跟随系统 模式(与 iOS UIInterfaceStyle 对齐) +@Riverpod(keepAlive: true) +class ThemeModeNotifier extends _$ThemeModeNotifier { + static const _keyMode = 'theme_mode'; + + @override + ThemeMode build() { + unawaited(_restore()); + return ThemeMode.light; + } + + void switchMode(ThemeMode mode) { + state = mode; + unawaited( + SharedPreferences.getInstance().then( + (prefs) => prefs.setString(_keyMode, mode.name), + ), + ); + } + + Future _restore() async { + final prefs = await SharedPreferences.getInstance(); + final name = prefs.getString(_keyMode); + if (name != null) { + state = ThemeMode.values.firstWhere( + (e) => e.name == name, + orElse: () => ThemeMode.light, + ); + } + } +} diff --git a/lib/core/theme/theme_notifier.g.dart b/lib/core/theme/theme_notifier.g.dart new file mode 100644 index 0000000..1388aed --- /dev/null +++ b/lib/core/theme/theme_notifier.g.dart @@ -0,0 +1,137 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'theme_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// 当前色板 Notifier — 对应 iOS AppThemeManager。 +/// +/// **默认 white**(白色导航栏 + 中性灰背景,对应设计稿默认配色)。 +/// 切换持久化到 SharedPreferences,下次启动恢复。 + +@ProviderFor(ThemeNotifier) +final themeProvider = ThemeNotifierProvider._(); + +/// 当前色板 Notifier — 对应 iOS AppThemeManager。 +/// +/// **默认 white**(白色导航栏 + 中性灰背景,对应设计稿默认配色)。 +/// 切换持久化到 SharedPreferences,下次启动恢复。 +final class ThemeNotifierProvider + extends $NotifierProvider { + /// 当前色板 Notifier — 对应 iOS AppThemeManager。 + /// + /// **默认 white**(白色导航栏 + 中性灰背景,对应设计稿默认配色)。 + /// 切换持久化到 SharedPreferences,下次启动恢复。 + ThemeNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'themeProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$themeNotifierHash(); + + @$internal + @override + ThemeNotifier create() => ThemeNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AppColorScheme value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$themeNotifierHash() => r'e4ed9671d872b5f592f2e5c732f355cf2efa217a'; + +/// 当前色板 Notifier — 对应 iOS AppThemeManager。 +/// +/// **默认 white**(白色导航栏 + 中性灰背景,对应设计稿默认配色)。 +/// 切换持久化到 SharedPreferences,下次启动恢复。 + +abstract class _$ThemeNotifier extends $Notifier { + AppColorScheme build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + AppColorScheme, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +/// 明亮 / 暗色 / 跟随系统 模式(与 iOS UIInterfaceStyle 对齐) + +@ProviderFor(ThemeModeNotifier) +final themeModeProvider = ThemeModeNotifierProvider._(); + +/// 明亮 / 暗色 / 跟随系统 模式(与 iOS UIInterfaceStyle 对齐) +final class ThemeModeNotifierProvider + extends $NotifierProvider { + /// 明亮 / 暗色 / 跟随系统 模式(与 iOS UIInterfaceStyle 对齐) + ThemeModeNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'themeModeProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$themeModeNotifierHash(); + + @$internal + @override + ThemeModeNotifier create() => ThemeModeNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ThemeMode value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$themeModeNotifierHash() => r'daa7db2830c3897ea7d834261955b00e35431e4c'; + +/// 明亮 / 暗色 / 跟随系统 模式(与 iOS UIInterfaceStyle 对齐) + +abstract class _$ThemeModeNotifier extends $Notifier { + ThemeMode build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ThemeMode, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/core/utils/password_validator.dart b/lib/core/utils/password_validator.dart new file mode 100644 index 0000000..f90220c --- /dev/null +++ b/lib/core/utils/password_validator.dart @@ -0,0 +1,30 @@ +/// 密码策略校验(12 位 + 大小写 + 数字 + 特殊符号) +/// +/// 对齐设计稿 #3/#5 要求:"密码不能少于12位,必须包含大小写字母、数字、特殊符号。" +String? validatePasswordPolicy(String pwd) { + if (pwd.length < 12) return '密码不能少于12位'; + if (!RegExp('[A-Z]').hasMatch(pwd)) return '需包含大写字母'; + if (!RegExp('[a-z]').hasMatch(pwd)) return '需包含小写字母'; + if (!RegExp('[0-9]').hasMatch(pwd)) return '需包含数字'; + if (!RegExp(r'[!@#$%^&*()\-_=+\[\]{};:,.<>?/|\\]').hasMatch(pwd)) { + return '需包含特殊符号'; + } + return null; +} + +/// 密码强度评分(用于 StrengthIndicator 重映射) +/// +/// 返回 0-3:0=不合格 / 1=弱 / 2=中 / 3=强(满足全部要求) +int passwordStrengthLevel(String pwd) { + if (pwd.length < 12) return 0; + var score = 0; + if (RegExp('[A-Z]').hasMatch(pwd)) score++; + if (RegExp('[a-z]').hasMatch(pwd)) score++; + if (RegExp('[0-9]').hasMatch(pwd)) score++; + if (RegExp(r'[!@#$%^&*()\-_=+\[\]{};:,.<>?/|\\]').hasMatch(pwd)) score++; + return switch (score) { + 0 || 1 => 1, + 2 || 3 => 2, + _ => 3, + }; +} diff --git a/lib/core/utils/sms_countdown_mixin.dart b/lib/core/utils/sms_countdown_mixin.dart new file mode 100644 index 0000000..99ce3b3 --- /dev/null +++ b/lib/core/utils/sms_countdown_mixin.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// 短信验证码倒计时 mixin — 抽自 phone_add / old_phone_verify 重复模板。 +/// +/// 用法(任意 [State] 子类,包含 ConsumerState): +/// ```dart +/// class _MyPageState extends ConsumerState +/// with SmsCountdownMixin { +/// +/// Future _sendCode() async { +/// await repo.sendSms(phone); +/// startSmsCountdown(); // 默认 60s +/// } +/// +/// @override +/// Widget build(BuildContext context) { +/// final text = isSmsCountingDown ? '${smsCountdown}s 后重发' : '发送验证码'; +/// ... +/// } +/// } +/// ``` +/// +/// dispose 时自动 cancel Timer,子类 `super.dispose()` 即可。 +mixin SmsCountdownMixin on State { + int _countdown = 0; + Timer? _timer; + + /// 当前剩余秒数(0 = 未运行) + int get smsCountdown => _countdown; + + /// 是否正在倒计时 + bool get isSmsCountingDown => _countdown > 0; + + /// 启动倒计时(重复调用会取消上一个 Timer 重新开始)。 + void startSmsCountdown({int seconds = 60}) { + _timer?.cancel(); + setState(() => _countdown = seconds); + _timer = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted) { + t.cancel(); + return; + } + setState(() { + _countdown -= 1; + if (_countdown <= 0) t.cancel(); + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} diff --git a/lib/core/utils/validator.dart b/lib/core/utils/validator.dart new file mode 100644 index 0000000..1065bcc --- /dev/null +++ b/lib/core/utils/validator.dart @@ -0,0 +1,21 @@ +// 对应 iOS BasicModule/Util/Validator.swift +// 所有校验统一在此,禁止在各 Page 内重复定义 +abstract class Validator { + static final _phoneReg = RegExp(r'^1[3-9]\d{9}$'); + static final _idCardReg = RegExp(r'^\d{17}[\dXx]$'); + // 密码:≥8位,含大小写字母和数字(iOS 原规则 ≥12 位,按需调整) + static final _passwordReg = RegExp( + r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d!@#$%^&*]{8,}$', + ); + + static bool isValidPhone(String phone) => _phoneReg.hasMatch(phone); + + static bool isValidIdCard(String id) => _idCardReg.hasMatch(id); + + static bool isValidPassword(String pwd) => _passwordReg.hasMatch(pwd); + + static bool isValidEmail(String email) => email.contains('@'); + + static bool isNotEmpty(String? value) => + value != null && value.trim().isNotEmpty; +} diff --git a/lib/core/widgets/app_button.dart b/lib/core/widgets/app_button.dart new file mode 100644 index 0000000..7d5a303 --- /dev/null +++ b/lib/core/widgets/app_button.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +/// 带 loading 状态的通用 FilledButton。 +class AppButton extends StatelessWidget { + const AppButton({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + this.width = double.infinity, + this.height = 50.0, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading; + final double width; + final double height; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: FilledButton( + onPressed: loading ? null : onPressed, + child: loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(label), + ), + ); + } +} + +/// 带 loading 状态的通用 OutlinedButton。 +class AppOutlinedButton extends StatelessWidget { + const AppOutlinedButton({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + this.width = double.infinity, + this.height = 50.0, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading; + final double width; + final double height; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: OutlinedButton( + onPressed: loading ? null : onPressed, + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ) + : Text(label), + ), + ); + } +} diff --git a/lib/core/widgets/app_text_field.dart b/lib/core/widgets/app_text_field.dart new file mode 100644 index 0000000..90d9a29 --- /dev/null +++ b/lib/core/widgets/app_text_field.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +/// 带标签、错误提示、前/后缀的通用输入框。 +class AppTextField extends StatelessWidget { + const AppTextField({ + super.key, + this.controller, + this.label, + this.hint, + this.errorText, + this.prefixIcon, + this.suffix, + this.obscureText = false, + this.keyboardType, + this.onChanged, + this.onSubmitted, + this.maxLength, + this.enabled = true, + this.readOnly = false, + this.focusNode, + }); + + final TextEditingController? controller; + final String? label; + final String? hint; + final String? errorText; + final Widget? prefixIcon; + final Widget? suffix; + final bool obscureText; + final TextInputType? keyboardType; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final int? maxLength; + final bool enabled; + final bool readOnly; + final FocusNode? focusNode; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + focusNode: focusNode, + obscureText: obscureText, + keyboardType: keyboardType, + onChanged: onChanged, + onSubmitted: onSubmitted, + maxLength: maxLength, + enabled: enabled, + readOnly: readOnly, + decoration: InputDecoration( + labelText: label, + hintText: hint, + errorText: errorText, + prefixIcon: prefixIcon, + suffix: suffix, + counterText: '', + ), + ); + } +} diff --git a/lib/core/widgets/app_toast.dart b/lib/core/widgets/app_toast.dart new file mode 100644 index 0000000..4b1b8d3 --- /dev/null +++ b/lib/core/widgets/app_toast.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +enum ToastType { info, success, error, warning } + +abstract final class AppToast { + static OverlayEntry? _current; + + static void show( + BuildContext context, + String message, { + ToastType type = ToastType.info, + Duration duration = const Duration(seconds: 4), + }) { + _current?.remove(); + _current = null; + + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (_) => _ToastBanner( + message: message, + type: type, + duration: duration, + onDismiss: () { + entry.remove(); + if (_current == entry) _current = null; + }, + ), + ); + _current = entry; + overlay.insert(entry); + } +} + +class _ToastBanner extends StatefulWidget { + const _ToastBanner({ + required this.message, + required this.type, + required this.duration, + required this.onDismiss, + }); + + final String message; + final ToastType type; + final Duration duration; + final VoidCallback onDismiss; + + @override + State<_ToastBanner> createState() => _ToastBannerState(); +} + +class _ToastBannerState extends State<_ToastBanner> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _slide; + late final Animation _fade; + Timer? _timer; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + ); + _slide = Tween( + begin: const Offset(0, -1.2), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic)); + _fade = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut); + + unawaited(_ctrl.forward()); + _timer = Timer(widget.duration, _dismiss); + } + + void _dismiss() { + _timer?.cancel(); + unawaited(_ctrl.reverse().then((_) => widget.onDismiss())); + } + + @override + void dispose() { + _timer?.cancel(); + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final (accentColor, icon) = switch (widget.type) { + ToastType.success => + (const Color(0xFF34C759), Icons.check_circle_outline_rounded), + ToastType.error => + (const Color(0xFFFF3B30), Icons.error_outline_rounded), + ToastType.warning => + (const Color(0xFFFF9F0A), Icons.warning_amber_rounded), + ToastType.info => + (const Color(0xFF6B63D9), Icons.info_outline_rounded), + }; + + final topPadding = MediaQuery.of(context).padding.top; + + return Positioned( + top: topPadding + 10.h, + left: 16.w, + right: 16.w, + child: SlideTransition( + position: _slide, + child: FadeTransition( + opacity: _fade, + child: Material( + elevation: 12, + shadowColor: Colors.black26, + borderRadius: BorderRadius.circular(14.r), + color: Colors.white, + child: ClipRRect( + borderRadius: BorderRadius.circular(14.r), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 4.w, color: accentColor), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 13.h, + ), + child: Row( + children: [ + Icon(icon, color: accentColor, size: 20.sp), + SizedBox(width: 10.w), + Expanded( + child: Text( + widget.message, + style: TextStyle( + color: const Color(0xFF1C1C1E), + fontSize: 14.sp, + fontWeight: FontWeight.w500, + height: 1.4, + ), + ), + ), + SizedBox(width: 8.w), + GestureDetector( + onTap: _dismiss, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: EdgeInsets.all(4.w), + child: Icon( + Icons.close_rounded, + color: const Color(0xFFAEAEB2), + size: 16.sp, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/avatar_widget.dart b/lib/core/widgets/avatar_widget.dart new file mode 100644 index 0000000..e887971 --- /dev/null +++ b/lib/core/widgets/avatar_widget.dart @@ -0,0 +1,53 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +/// 用户头像,支持网络图片 + 首字母 fallback。 +class AvatarWidget extends StatelessWidget { + const AvatarWidget({ + super.key, + this.url, + this.name, + this.size = 40, + }); + + final String? url; + final String? name; + final double size; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final radius = size / 2; + + if (url != null && url!.isNotEmpty) { + return CachedNetworkImage( + imageUrl: url!, + imageBuilder: (_, img) => CircleAvatar( + radius: radius, + backgroundImage: img, + ), + placeholder: (_, __) => _placeholder(context), + errorWidget: (_, __, ___) => _placeholder(context), + ); + } + return _placeholder(context); + } + + Widget _placeholder(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final initial = + (name?.isNotEmpty == true) ? name![0].toUpperCase() : '?'; + return CircleAvatar( + radius: size / 2, + backgroundColor: colorScheme.primaryContainer, + child: Text( + initial, + style: TextStyle( + fontSize: size * 0.4, + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/core/widgets/confirm_dialog.dart b/lib/core/widgets/confirm_dialog.dart new file mode 100644 index 0000000..b6d3a49 --- /dev/null +++ b/lib/core/widgets/confirm_dialog.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +/// 通用确认对话框。 +/// +/// 用法: +/// ```dart +/// final ok = await ConfirmDialog.show( +/// context, +/// title: '删除确认', +/// message: '确定要删除该记录吗?', +/// confirmLabel: '删除', +/// destructive: true, +/// ); +/// if (ok == true) { /* do it */ } +/// ``` +class ConfirmDialog extends StatelessWidget { + const ConfirmDialog({ + super.key, + required this.title, + required this.message, + this.confirmLabel = '确定', + this.cancelLabel = '取消', + this.destructive = false, + }); + + final String title; + final String message; + final String confirmLabel; + final String cancelLabel; + final bool destructive; + + static Future show( + BuildContext context, { + required String title, + required String message, + String confirmLabel = '确定', + String cancelLabel = '取消', + bool destructive = false, + }) { + return showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: title, + message: message, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + destructive: destructive, + ), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(cancelLabel), + ), + TextButton( + style: destructive + ? TextButton.styleFrom(foregroundColor: colorScheme.error) + : null, + onPressed: () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ), + ], + ); + } +} diff --git a/lib/core/widgets/count_down_button.dart b/lib/core/widgets/count_down_button.dart new file mode 100644 index 0000000..b1b8531 --- /dev/null +++ b/lib/core/widgets/count_down_button.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// 带倒计时的发送按钮(常用于短信验证码)。 +/// +/// 倒计时结束后自动恢复可点击状态。 +class CountDownButton extends StatefulWidget { + const CountDownButton({ + super.key, + required this.onSend, + this.label = '发送验证码', + this.countingLabel = '重新发送', + this.seconds = 60, + }); + + final VoidCallback onSend; + final String label; + final String countingLabel; + final int seconds; + + @override + State createState() => _CountDownButtonState(); +} + +class _CountDownButtonState extends State { + int _remaining = 0; + Timer? _timer; + + bool get _isCounting => _remaining > 0; + + void _start() { + widget.onSend(); + setState(() => _remaining = widget.seconds); + _timer = Timer.periodic(const Duration(seconds: 1), (t) { + if (_remaining <= 1) { + t.cancel(); + if (mounted) setState(() => _remaining = 0); + } else { + if (mounted) setState(() => _remaining--); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: _isCounting ? null : _start, + child: Text( + _isCounting ? '${widget.countingLabel}(${_remaining}s)' : widget.label, + ), + ); + } +} diff --git a/lib/core/widgets/empty_view.dart b/lib/core/widgets/empty_view.dart new file mode 100644 index 0000000..a0cffe0 --- /dev/null +++ b/lib/core/widgets/empty_view.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +/// 空状态占位视图。 +class EmptyView extends StatelessWidget { + const EmptyView({ + super.key, + this.message = '暂无数据', + this.icon, + this.action, + }); + + final String message; + final Widget? icon; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + icon ?? + Icon( + Icons.inbox_outlined, + size: 64, + color: Theme.of(context).colorScheme.outlineVariant, + ), + const SizedBox(height: 16), + Text( + message, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + textAlign: TextAlign.center, + ), + if (action != null) ...[const SizedBox(height: 24), action!], + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/error_view.dart b/lib/core/widgets/error_view.dart new file mode 100644 index 0000000..b39ca90 --- /dev/null +++ b/lib/core/widgets/error_view.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +/// 错误状态视图,提供重试按钮。 +class ErrorView extends StatelessWidget { + const ErrorView({ + super.key, + this.message = '加载失败,请重试', + this.onRetry, + }); + + final String message; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 56, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + message, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + const SizedBox(height: 24), + FilledButton.tonal( + onPressed: onRetry, + child: const Text('重试'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/info_row.dart b/lib/core/widgets/info_row.dart new file mode 100644 index 0000000..a8d2584 --- /dev/null +++ b/lib/core/widgets/info_row.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +/// 标签 + 值 的水平信息行,常用于详情页和调试面板。 +class InfoRow extends StatelessWidget { + const InfoRow({ + super.key, + required this.label, + required this.value, + this.valueStyle, + this.crossAxisAlignment = CrossAxisAlignment.center, + }); + + final String label; + final String value; + final TextStyle? valueStyle; + final CrossAxisAlignment crossAxisAlignment; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: crossAxisAlignment, + children: [ + SizedBox( + width: 88, + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + value, + style: valueStyle ?? theme.textTheme.bodySmall, + ), + ), + ], + ), + ); + } +} diff --git a/lib/core/widgets/loading_overlay.dart b/lib/core/widgets/loading_overlay.dart new file mode 100644 index 0000000..22f2990 --- /dev/null +++ b/lib/core/widgets/loading_overlay.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// 全屏/局部 loading 遮罩。 +/// +/// 用法: +/// ```dart +/// LoadingOverlay( +/// loading: _isLoading, +/// child: MyWidget(), +/// ) +/// ``` +class LoadingOverlay extends StatelessWidget { + const LoadingOverlay({ + super.key, + required this.child, + this.loading = false, + this.label, + }); + + final Widget child; + final bool loading; + final String? label; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + child, + if (loading) + Positioned.fill( + child: ColoredBox( + color: Colors.black26, + child: Center( + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + if (label != null) ...[ + const SizedBox(height: 12), + Text(label!, + style: Theme.of(context).textTheme.bodySmall), + ], + ], + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/core/widgets/section_card.dart b/lib/core/widgets/section_card.dart new file mode 100644 index 0000000..7b4e812 --- /dev/null +++ b/lib/core/widgets/section_card.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +/// 带圆角边框的内容分组卡片。 +class SectionCard extends StatelessWidget { + const SectionCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(16), + this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + }); + + final Widget child; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry margin; + + @override + Widget build(BuildContext context) { + return Container( + margin: margin, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding(padding: padding, child: child), + ); + } +} diff --git a/lib/core/widgets/section_title.dart b/lib/core/widgets/section_title.dart new file mode 100644 index 0000000..411886c --- /dev/null +++ b/lib/core/widgets/section_title.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// 带左侧装饰条的区块标题。 +class SectionTitle extends StatelessWidget { + const SectionTitle(this.title, {super.key, this.trailing}); + + final String title; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final primary = Theme.of(context).colorScheme.primary; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Container( + width: 3, + height: 16, + decoration: BoxDecoration( + color: primary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + } +} diff --git a/lib/core/widgets/shell_scaffold.dart b/lib/core/widgets/shell_scaffold.dart new file mode 100644 index 0000000..7701de4 --- /dev/null +++ b/lib/core/widgets/shell_scaffold.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// 底部导航 Shell — 2 Tab(Home / Mine)。 +/// Tab 标签文字在 Task 5 替换为 i18n 字符串。 +class ShellScaffold extends StatelessWidget { + const ShellScaffold({super.key, required this.shell}); + + final StatefulNavigationShell shell; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: shell, + bottomNavigationBar: NavigationBar( + selectedIndex: shell.currentIndex, + onDestinationSelected: (index) => shell.goBranch( + index, + initialLocation: index == shell.currentIndex, + ), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', // TODO: i18n → t.tab.home + ), + NavigationDestination( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: 'Mine', // TODO: i18n → t.tab.mine + ), + ], + ), + ); + } +} diff --git a/lib/core/widgets/skeleton.dart b/lib/core/widgets/skeleton.dart new file mode 100644 index 0000000..d3ba2e2 --- /dev/null +++ b/lib/core/widgets/skeleton.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +/// 骨架屏占位矩形。 +/// +/// 用法(配合 shimmer 包): +/// ```dart +/// Shimmer.fromColors( +/// baseColor: Colors.grey[300]!, +/// highlightColor: Colors.grey[100]!, +/// child: SkeletonBox(width: double.infinity, height: 20), +/// ) +/// ``` +class SkeletonBox extends StatelessWidget { + const SkeletonBox({ + super.key, + this.width, + this.height = 14, + this.borderRadius = 6, + }); + + final double? width; + final double height; + final double borderRadius; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(borderRadius), + ), + ); + } +} + +/// 骨架屏圆形(头像占位)。 +class SkeletonCircle extends StatelessWidget { + const SkeletonCircle({super.key, this.size = 40}); + + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + ); + } +} diff --git a/lib/core/widgets/tag_chip.dart b/lib/core/widgets/tag_chip.dart new file mode 100644 index 0000000..9636f7b --- /dev/null +++ b/lib/core/widgets/tag_chip.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// 彩色标签 Chip,常用于状态、分类标注。 +class TagChip extends StatelessWidget { + const TagChip({ + super.key, + required this.label, + this.color, + this.textColor, + this.fontSize = 12, + }); + + final String label; + final Color? color; + final Color? textColor; + final double fontSize; + + @override + Widget build(BuildContext context) { + final primary = Theme.of(context).colorScheme.primary; + final bg = color ?? primary.withValues(alpha: 0.12); + final fg = textColor ?? primary; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: TextStyle( + color: fg, + fontSize: fontSize, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} diff --git a/lib/features/auth/auth_providers.dart b/lib/features/auth/auth_providers.dart new file mode 100644 index 0000000..9132d19 --- /dev/null +++ b/lib/features/auth/auth_providers.dart @@ -0,0 +1,3 @@ +// Auth feature provider 出口 — presentation 层通过此文件访问 authRepositoryProvider +export 'package:sunny_mochi/features/auth/data/repositories/auth_repository_impl.dart' + show authRepositoryProvider; diff --git a/lib/features/auth/data/datasources/auth_remote_datasource.dart b/lib/features/auth/data/datasources/auth_remote_datasource.dart new file mode 100644 index 0000000..8c85f2e --- /dev/null +++ b/lib/features/auth/data/datasources/auth_remote_datasource.dart @@ -0,0 +1,133 @@ +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sunny_mochi/core/config/api_paths.dart'; +import 'package:sunny_mochi/core/error/exception_mapper.dart'; +import 'package:sunny_mochi/core/error/failures.dart'; +import 'package:sunny_mochi/core/network/api_response.dart'; +import 'package:sunny_mochi/core/network/dio_client.dart'; +import 'package:sunny_mochi/features/auth/data/models/user_model.dart'; + +part 'auth_remote_datasource.g.dart'; + +@riverpod +AuthRemoteDatasource authRemoteDatasource(Ref ref) => + AuthRemoteDatasource(ref.watch(dioClientProvider)); + +class AuthRemoteDatasource { + const AuthRemoteDatasource(this._dio); + final Dio _dio; + + static const _mapper = ExceptionMapper(); + + /// 登录/发码端点跳过 AuthInterceptor,避免携带残留 token。 + static final _skipAuthOptions = Options(extra: const {'skip_auth': true}); + + Future loginWithPhone(String phone, String code) async { + final resp = await _dio.post>( + ApiPaths.authLoginSms, + data: {'account': phone, 'smsCode': code, 'loginType': 2}, + options: _skipAuthOptions, + ); + return _unwrapLogin(resp.data); + } + + Future loginWithPassword(String phone, String password) async { + final resp = await _dio.post>( + ApiPaths.authLoginPwd, + data: {'account': phone, 'password': password, 'loginType': 1}, + options: _skipAuthOptions, + ); + return _unwrapLogin(resp.data); + } + + /// 返回 dev/test 环境后端回显的验证码明文(release 返回 null)。 + Future sendSmsCode(String phone) async { + final resp = await _dio.post>( + ApiPaths.authSmsSend, + data: {'phone': phone, 'codeType': 1}, + options: _skipAuthOptions, + ); + final apiResp = parseEnvelope(resp.data, (j) => j as String?); + if (!apiResp.isSuccess) { + throw _mapper.fromBusinessCode(apiResp.retCode, apiResp.retMsg); + } + return apiResp.retData; + } + + Future logout() async { + await _dio.post(ApiPaths.authLogout); + } + + Future getUserProfile() async { + final resp = await _dio.get>(ApiPaths.userProfile); + final body = resp.data; + if (body == null) throw const ServerFailure(message: '服务器响应为空'); + final apiResp = ApiResponse.fromJson( + body, + (json) => json as Map, + ); + if (!apiResp.isSuccess) { + throw _mapper.fromBusinessCode(apiResp.retCode, apiResp.retMsg); + } + final data = apiResp.retData ?? const {}; + return UserModel( + userId: _flexStr(data['id'] ?? data['userId']), + realName: _clean(data['realName']), + username: _clean(data['realName']), + avatar: _clean(data['avatar']), + phone: _flexStr(data['phone']), + email: _clean(data['email']), + gender: (data['sex'] as num?)?.toInt(), + employeeNo: _clean(data['workNo']), + company: _clean(data['orgName']), + department: _clean(data['deptName']), + ); + } + + static String? _clean(dynamic v) { + if (v == null) return null; + final s = v.toString(); + if (s.isEmpty || s == '' || s == 'null') return null; + return s; + } + + /// 解析登录 envelope:data = {saTokenInfo: {...}, userInfo: {...}} + UserModel _unwrapLogin(Map? body) { + if (body == null) throw const ServerFailure(message: '服务器响应为空'); + final apiResp = ApiResponse.fromJson( + body, + (json) => json as Map, + ); + if (!apiResp.isSuccess) { + throw _mapper.fromBusinessCode(apiResp.retCode, apiResp.retMsg); + } + final data = apiResp.retData; + if (data == null) throw const ServerFailure(message: '登录响应缺少用户数据'); + final tokenInfo = data['saTokenInfo'] as Map?; + final userInfo = data['userInfo'] as Map?; + if (tokenInfo == null || userInfo == null) { + throw const ServerFailure(message: '登录响应结构异常'); + } + return UserModel( + token: tokenInfo['tokenValue'] as String?, + tokenName: tokenInfo['tokenName'] as String?, + userId: _flexStr(tokenInfo['loginId']), + realName: userInfo['realName'] as String?, + username: userInfo['realName'] as String?, + avatar: userInfo['avatar'] as String?, + phone: _flexStr(userInfo['phone']), + email: userInfo['email'] as String?, + gender: (userInfo['sex'] as num?)?.toInt(), + employeeNo: userInfo['workNo'] as String?, + company: userInfo['orgName'] as String?, + department: userInfo['deptName'] as String?, + ); + } + + static String? _flexStr(dynamic v) { + if (v == null) return null; + if (v is String) return v.isEmpty ? null : v; + if (v is int) return v.toString(); + return null; + } +} diff --git a/lib/features/auth/data/datasources/auth_remote_datasource.g.dart b/lib/features/auth/data/datasources/auth_remote_datasource.g.dart new file mode 100644 index 0000000..635b514 --- /dev/null +++ b/lib/features/auth/data/datasources/auth_remote_datasource.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_remote_datasource.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(authRemoteDatasource) +final authRemoteDatasourceProvider = AuthRemoteDatasourceProvider._(); + +final class AuthRemoteDatasourceProvider + extends + $FunctionalProvider< + AuthRemoteDatasource, + AuthRemoteDatasource, + AuthRemoteDatasource + > + with $Provider { + AuthRemoteDatasourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'authRemoteDatasourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$authRemoteDatasourceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + AuthRemoteDatasource create(Ref ref) { + return authRemoteDatasource(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AuthRemoteDatasource value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$authRemoteDatasourceHash() => + r'81f99f43779be5f073a2953ab9a3c1b80883aa9e'; diff --git a/lib/features/auth/data/models/user_model.dart b/lib/features/auth/data/models/user_model.dart new file mode 100644 index 0000000..dd66037 --- /dev/null +++ b/lib/features/auth/data/models/user_model.dart @@ -0,0 +1,42 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:sunny_mochi/features/auth/domain/entities/user_entity.dart'; + +part 'user_model.freezed.dart'; +part 'user_model.g.dart'; + +@freezed +abstract class UserModel with _$UserModel { + const factory UserModel({ + String? userId, + String? username, + String? realName, + String? phone, + String? avatar, + String? token, + String? tokenName, + String? refreshToken, + String? email, + DateTime? birthday, + int? gender, + String? employeeNo, + String? company, + String? department, + }) = _UserModel; + + factory UserModel.fromJson(Map json) => + _$UserModelFromJson(json); +} + +extension UserModelX on UserModel { + UserEntity toEntity() => UserEntity( + userId: userId, + username: username, + realName: realName, + phone: phone, + avatar: avatar, + token: token, + employeeNo: employeeNo, + company: company, + department: department, + ); +} diff --git a/lib/features/auth/data/models/user_model.freezed.dart b/lib/features/auth/data/models/user_model.freezed.dart new file mode 100644 index 0000000..d82761f --- /dev/null +++ b/lib/features/auth/data/models/user_model.freezed.dart @@ -0,0 +1,316 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'user_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$UserModel { + + String? get userId; String? get username; String? get realName; String? get phone; String? get avatar; String? get token; String? get tokenName; String? get refreshToken; String? get email; DateTime? get birthday; int? get gender; String? get employeeNo; String? get company; String? get department; +/// Create a copy of UserModel +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$UserModelCopyWith get copyWith => _$UserModelCopyWithImpl(this as UserModel, _$identity); + + /// Serializes this UserModel to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is UserModel&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.username, username) || other.username == username)&&(identical(other.realName, realName) || other.realName == realName)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.token, token) || other.token == token)&&(identical(other.tokenName, tokenName) || other.tokenName == tokenName)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)&&(identical(other.email, email) || other.email == email)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.employeeNo, employeeNo) || other.employeeNo == employeeNo)&&(identical(other.company, company) || other.company == company)&&(identical(other.department, department) || other.department == department)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,userId,username,realName,phone,avatar,token,tokenName,refreshToken,email,birthday,gender,employeeNo,company,department); + +@override +String toString() { + return 'UserModel(userId: $userId, username: $username, realName: $realName, phone: $phone, avatar: $avatar, token: $token, tokenName: $tokenName, refreshToken: $refreshToken, email: $email, birthday: $birthday, gender: $gender, employeeNo: $employeeNo, company: $company, department: $department)'; +} + + +} + +/// @nodoc +abstract mixin class $UserModelCopyWith<$Res> { + factory $UserModelCopyWith(UserModel value, $Res Function(UserModel) _then) = _$UserModelCopyWithImpl; +@useResult +$Res call({ + String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? tokenName, String? refreshToken, String? email, DateTime? birthday, int? gender, String? employeeNo, String? company, String? department +}); + + + + +} +/// @nodoc +class _$UserModelCopyWithImpl<$Res> + implements $UserModelCopyWith<$Res> { + _$UserModelCopyWithImpl(this._self, this._then); + + final UserModel _self; + final $Res Function(UserModel) _then; + +/// Create a copy of UserModel +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? userId = freezed,Object? username = freezed,Object? realName = freezed,Object? phone = freezed,Object? avatar = freezed,Object? token = freezed,Object? tokenName = freezed,Object? refreshToken = freezed,Object? email = freezed,Object? birthday = freezed,Object? gender = freezed,Object? employeeNo = freezed,Object? company = freezed,Object? department = freezed,}) { + return _then(_self.copyWith( +userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String?,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String?,realName: freezed == realName ? _self.realName : realName // ignore: cast_nullable_to_non_nullable +as String?,phone: freezed == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable +as String?,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable +as String?,token: freezed == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String?,tokenName: freezed == tokenName ? _self.tokenName : tokenName // ignore: cast_nullable_to_non_nullable +as String?,refreshToken: freezed == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable +as String?,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String?,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable +as DateTime?,gender: freezed == gender ? _self.gender : gender // ignore: cast_nullable_to_non_nullable +as int?,employeeNo: freezed == employeeNo ? _self.employeeNo : employeeNo // ignore: cast_nullable_to_non_nullable +as String?,company: freezed == company ? _self.company : company // ignore: cast_nullable_to_non_nullable +as String?,department: freezed == department ? _self.department : department // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [UserModel]. +extension UserModelPatterns on UserModel { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _UserModel value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _UserModel() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _UserModel value) $default,){ +final _that = this; +switch (_that) { +case _UserModel(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _UserModel value)? $default,){ +final _that = this; +switch (_that) { +case _UserModel() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? tokenName, String? refreshToken, String? email, DateTime? birthday, int? gender, String? employeeNo, String? company, String? department)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _UserModel() when $default != null: +return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.tokenName,_that.refreshToken,_that.email,_that.birthday,_that.gender,_that.employeeNo,_that.company,_that.department);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? tokenName, String? refreshToken, String? email, DateTime? birthday, int? gender, String? employeeNo, String? company, String? department) $default,) {final _that = this; +switch (_that) { +case _UserModel(): +return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.tokenName,_that.refreshToken,_that.email,_that.birthday,_that.gender,_that.employeeNo,_that.company,_that.department);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? tokenName, String? refreshToken, String? email, DateTime? birthday, int? gender, String? employeeNo, String? company, String? department)? $default,) {final _that = this; +switch (_that) { +case _UserModel() when $default != null: +return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.tokenName,_that.refreshToken,_that.email,_that.birthday,_that.gender,_that.employeeNo,_that.company,_that.department);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _UserModel implements UserModel { + const _UserModel({this.userId, this.username, this.realName, this.phone, this.avatar, this.token, this.tokenName, this.refreshToken, this.email, this.birthday, this.gender, this.employeeNo, this.company, this.department}); + factory _UserModel.fromJson(Map json) => _$UserModelFromJson(json); + +@override final String? userId; +@override final String? username; +@override final String? realName; +@override final String? phone; +@override final String? avatar; +@override final String? token; +@override final String? tokenName; +@override final String? refreshToken; +@override final String? email; +@override final DateTime? birthday; +@override final int? gender; +@override final String? employeeNo; +@override final String? company; +@override final String? department; + +/// Create a copy of UserModel +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$UserModelCopyWith<_UserModel> get copyWith => __$UserModelCopyWithImpl<_UserModel>(this, _$identity); + +@override +Map toJson() { + return _$UserModelToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _UserModel&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.username, username) || other.username == username)&&(identical(other.realName, realName) || other.realName == realName)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.token, token) || other.token == token)&&(identical(other.tokenName, tokenName) || other.tokenName == tokenName)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)&&(identical(other.email, email) || other.email == email)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.employeeNo, employeeNo) || other.employeeNo == employeeNo)&&(identical(other.company, company) || other.company == company)&&(identical(other.department, department) || other.department == department)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,userId,username,realName,phone,avatar,token,tokenName,refreshToken,email,birthday,gender,employeeNo,company,department); + +@override +String toString() { + return 'UserModel(userId: $userId, username: $username, realName: $realName, phone: $phone, avatar: $avatar, token: $token, tokenName: $tokenName, refreshToken: $refreshToken, email: $email, birthday: $birthday, gender: $gender, employeeNo: $employeeNo, company: $company, department: $department)'; +} + + +} + +/// @nodoc +abstract mixin class _$UserModelCopyWith<$Res> implements $UserModelCopyWith<$Res> { + factory _$UserModelCopyWith(_UserModel value, $Res Function(_UserModel) _then) = __$UserModelCopyWithImpl; +@override @useResult +$Res call({ + String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? tokenName, String? refreshToken, String? email, DateTime? birthday, int? gender, String? employeeNo, String? company, String? department +}); + + + + +} +/// @nodoc +class __$UserModelCopyWithImpl<$Res> + implements _$UserModelCopyWith<$Res> { + __$UserModelCopyWithImpl(this._self, this._then); + + final _UserModel _self; + final $Res Function(_UserModel) _then; + +/// Create a copy of UserModel +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? userId = freezed,Object? username = freezed,Object? realName = freezed,Object? phone = freezed,Object? avatar = freezed,Object? token = freezed,Object? tokenName = freezed,Object? refreshToken = freezed,Object? email = freezed,Object? birthday = freezed,Object? gender = freezed,Object? employeeNo = freezed,Object? company = freezed,Object? department = freezed,}) { + return _then(_UserModel( +userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String?,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String?,realName: freezed == realName ? _self.realName : realName // ignore: cast_nullable_to_non_nullable +as String?,phone: freezed == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable +as String?,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable +as String?,token: freezed == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String?,tokenName: freezed == tokenName ? _self.tokenName : tokenName // ignore: cast_nullable_to_non_nullable +as String?,refreshToken: freezed == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable +as String?,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String?,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable +as DateTime?,gender: freezed == gender ? _self.gender : gender // ignore: cast_nullable_to_non_nullable +as int?,employeeNo: freezed == employeeNo ? _self.employeeNo : employeeNo // ignore: cast_nullable_to_non_nullable +as String?,company: freezed == company ? _self.company : company // ignore: cast_nullable_to_non_nullable +as String?,department: freezed == department ? _self.department : department // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/features/auth/data/models/user_model.g.dart b/lib/features/auth/data/models/user_model.g.dart new file mode 100644 index 0000000..4ce4498 --- /dev/null +++ b/lib/features/auth/data/models/user_model.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_UserModel _$UserModelFromJson(Map json) => _UserModel( + userId: json['userId'] as String?, + username: json['username'] as String?, + realName: json['realName'] as String?, + phone: json['phone'] as String?, + avatar: json['avatar'] as String?, + token: json['token'] as String?, + tokenName: json['tokenName'] as String?, + refreshToken: json['refreshToken'] as String?, + email: json['email'] as String?, + birthday: json['birthday'] == null + ? null + : DateTime.parse(json['birthday'] as String), + gender: (json['gender'] as num?)?.toInt(), + employeeNo: json['employeeNo'] as String?, + company: json['company'] as String?, + department: json['department'] as String?, +); + +Map _$UserModelToJson(_UserModel instance) => + { + 'userId': instance.userId, + 'username': instance.username, + 'realName': instance.realName, + 'phone': instance.phone, + 'avatar': instance.avatar, + 'token': instance.token, + 'tokenName': instance.tokenName, + 'refreshToken': instance.refreshToken, + 'email': instance.email, + 'birthday': instance.birthday?.toIso8601String(), + 'gender': instance.gender, + 'employeeNo': instance.employeeNo, + 'company': instance.company, + 'department': instance.department, + }; diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart new file mode 100644 index 0000000..3f2f359 --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:drift/drift.dart' show Value; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sunny_mochi/core/config/env.dart'; +import 'package:sunny_mochi/core/crypto/rsa_helper.dart'; +import 'package:sunny_mochi/core/error/failures.dart'; +import 'package:sunny_mochi/core/storage/app_database.dart'; +import 'package:sunny_mochi/core/storage/daos/users_dao.dart'; +import 'package:sunny_mochi/core/storage/secure_storage.dart'; +import 'package:sunny_mochi/features/auth/data/datasources/auth_remote_datasource.dart'; +import 'package:sunny_mochi/features/auth/data/models/user_model.dart'; +import 'package:sunny_mochi/features/auth/domain/entities/user_entity.dart'; +import 'package:sunny_mochi/features/auth/domain/repositories/auth_repository.dart'; + +part 'auth_repository_impl.g.dart'; + +@riverpod +AuthRepository authRepository(Ref ref) => AuthRepositoryImpl( + ref.watch(authRemoteDatasourceProvider), + ref.watch(secureStorageProvider), + usersDaoFactory: () => ref.read(usersDaoProvider), + ); + +class AuthRepositoryImpl implements AuthRepository { + AuthRepositoryImpl( + this._remote, + this._storage, { + String? rsaPublicKey, + UsersDao Function()? usersDaoFactory, + }) : _rsaPublicKey = rsaPublicKey ?? Env.rsaPublicKey, + _usersDaoFactory = usersDaoFactory; + + final AuthRemoteDatasource _remote; + final SecureStorage _storage; + final String _rsaPublicKey; + final UsersDao Function()? _usersDaoFactory; + + @override + Future loginWithPhone({ + required String phone, + required String code, + }) async { + final model = await _remote.loginWithPhone(phone, code); + await _persistUser(model); + return model.toEntity(); + } + + @override + Future loginWithPassword({ + required String phone, + required String password, + }) async { + final String encrypted; + try { + encrypted = RsaHelper.encrypt(password, publicKeyDerBase64: _rsaPublicKey); + } on RsaEncryptionException catch (e, st) { + throw AuthFailure( + message: '密码加密失败,请重试', + kind: AuthFailureKind.cryptoFailed, + cause: e, + stackTrace: st, + ); + } + final model = await _remote.loginWithPassword(phone, encrypted); + await _persistUser(model); + return model.toEntity(); + } + + @override + Future logout() async { + final userId = await _storage.getUserId(); + await _storage.clearAll(); + unawaited(_backgroundLogoutCleanup(userId)); + } + + Future _backgroundLogoutCleanup(String? userId) async { + try { + await _remote.logout(); + } on Object { + // best-effort + } + if (userId != null && _usersDaoFactory != null) { + try { + await _usersDaoFactory().deleteById(userId); + } on Object { + // mock/测试场景静默 + } + } + } + + @override + Future getCurrentUser() async { + final token = await _storage.getToken(); + if (token == null) return null; + final userId = await _storage.getUserId(); + if (userId != null && _usersDaoFactory != null) { + try { + final row = await _usersDaoFactory().getById(userId); + if (row != null) { + return UserEntity( + userId: userId, + token: token, + username: row.username, + realName: row.realName, + avatar: row.avatar, + phone: row.phone, + employeeNo: row.employeeNo, + company: row.company, + department: row.department, + ); + } + } on Object { + // Drift 不可用时降级 + } + } + return UserEntity(userId: userId, token: token); + } + + @override + Future sendSmsCode(String phone) => _remote.sendSmsCode(phone); + + @override + Future fetchUserProfile() async { + final model = await _remote.getUserProfile(); + final userId = await _storage.getUserId(); + if (userId != null && _usersDaoFactory != null) { + try { + await _usersDaoFactory().upsert( + UsersCompanion( + userId: Value(userId), + username: Value(model.username), + realName: Value(model.realName), + phone: Value(model.phone), + avatar: Value(model.avatar), + email: Value(model.email), + gender: Value(model.gender), + employeeNo: Value(model.employeeNo), + company: Value(model.company), + department: Value(model.department), + ), + ); + } on Object { + // dao 不可用静默 + } + } + return model.toEntity(); + } + + Future _persistUser(UserModel model) async { + if (model.token != null) await _storage.setToken(model.token!); + if (model.tokenName != null && model.tokenName!.isNotEmpty) { + await _storage.setTokenName(model.tokenName!); + } + if (model.refreshToken != null) { + await _storage.setRefreshToken(model.refreshToken!); + } + if (model.userId != null) await _storage.setUserId(model.userId!); + final userId = model.userId; + if (userId != null && _usersDaoFactory != null) { + try { + await _usersDaoFactory().upsert( + UsersCompanion( + userId: Value(userId), + username: Value(model.username), + realName: Value(model.realName), + phone: Value(model.phone), + avatar: Value(model.avatar), + email: Value(model.email), + birthday: Value(model.birthday), + gender: Value(model.gender), + refreshToken: Value(model.refreshToken), + ), + ); + } on Object { + // dao 不可用静默 + } + } + } +} diff --git a/lib/features/auth/data/repositories/auth_repository_impl.g.dart b/lib/features/auth/data/repositories/auth_repository_impl.g.dart new file mode 100644 index 0000000..1d48eeb --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_impl.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_repository_impl.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(authRepository) +final authRepositoryProvider = AuthRepositoryProvider._(); + +final class AuthRepositoryProvider + extends $FunctionalProvider + with $Provider { + AuthRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'authRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$authRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + AuthRepository create(Ref ref) { + return authRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AuthRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$authRepositoryHash() => r'3a8f259bef644802bc3ef4169a8456a3a05aaea8'; diff --git a/lib/features/auth/domain/entities/user_entity.dart b/lib/features/auth/domain/entities/user_entity.dart new file mode 100644 index 0000000..91246af --- /dev/null +++ b/lib/features/auth/domain/entities/user_entity.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'user_entity.freezed.dart'; + +@freezed +abstract class UserEntity with _$UserEntity { + const factory UserEntity({ + String? userId, + String? username, + String? realName, + String? phone, + String? avatar, + String? token, + String? employeeNo, + String? company, + String? department, + }) = _UserEntity; +} diff --git a/lib/features/auth/domain/entities/user_entity.freezed.dart b/lib/features/auth/domain/entities/user_entity.freezed.dart new file mode 100644 index 0000000..5029b3e --- /dev/null +++ b/lib/features/auth/domain/entities/user_entity.freezed.dart @@ -0,0 +1,295 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'user_entity.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$UserEntity { + + String? get userId; String? get username; String? get realName; String? get phone; String? get avatar; String? get token; String? get employeeNo; String? get company; String? get department; +/// Create a copy of UserEntity +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$UserEntityCopyWith get copyWith => _$UserEntityCopyWithImpl(this as UserEntity, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is UserEntity&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.username, username) || other.username == username)&&(identical(other.realName, realName) || other.realName == realName)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.token, token) || other.token == token)&&(identical(other.employeeNo, employeeNo) || other.employeeNo == employeeNo)&&(identical(other.company, company) || other.company == company)&&(identical(other.department, department) || other.department == department)); +} + + +@override +int get hashCode => Object.hash(runtimeType,userId,username,realName,phone,avatar,token,employeeNo,company,department); + +@override +String toString() { + return 'UserEntity(userId: $userId, username: $username, realName: $realName, phone: $phone, avatar: $avatar, token: $token, employeeNo: $employeeNo, company: $company, department: $department)'; +} + + +} + +/// @nodoc +abstract mixin class $UserEntityCopyWith<$Res> { + factory $UserEntityCopyWith(UserEntity value, $Res Function(UserEntity) _then) = _$UserEntityCopyWithImpl; +@useResult +$Res call({ + String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? employeeNo, String? company, String? department +}); + + + + +} +/// @nodoc +class _$UserEntityCopyWithImpl<$Res> + implements $UserEntityCopyWith<$Res> { + _$UserEntityCopyWithImpl(this._self, this._then); + + final UserEntity _self; + final $Res Function(UserEntity) _then; + +/// Create a copy of UserEntity +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? userId = freezed,Object? username = freezed,Object? realName = freezed,Object? phone = freezed,Object? avatar = freezed,Object? token = freezed,Object? employeeNo = freezed,Object? company = freezed,Object? department = freezed,}) { + return _then(_self.copyWith( +userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String?,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String?,realName: freezed == realName ? _self.realName : realName // ignore: cast_nullable_to_non_nullable +as String?,phone: freezed == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable +as String?,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable +as String?,token: freezed == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String?,employeeNo: freezed == employeeNo ? _self.employeeNo : employeeNo // ignore: cast_nullable_to_non_nullable +as String?,company: freezed == company ? _self.company : company // ignore: cast_nullable_to_non_nullable +as String?,department: freezed == department ? _self.department : department // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [UserEntity]. +extension UserEntityPatterns on UserEntity { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _UserEntity value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _UserEntity() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _UserEntity value) $default,){ +final _that = this; +switch (_that) { +case _UserEntity(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _UserEntity value)? $default,){ +final _that = this; +switch (_that) { +case _UserEntity() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? employeeNo, String? company, String? department)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _UserEntity() when $default != null: +return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.employeeNo,_that.company,_that.department);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? employeeNo, String? company, String? department) $default,) {final _that = this; +switch (_that) { +case _UserEntity(): +return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.employeeNo,_that.company,_that.department);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? employeeNo, String? company, String? department)? $default,) {final _that = this; +switch (_that) { +case _UserEntity() when $default != null: +return $default(_that.userId,_that.username,_that.realName,_that.phone,_that.avatar,_that.token,_that.employeeNo,_that.company,_that.department);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _UserEntity implements UserEntity { + const _UserEntity({this.userId, this.username, this.realName, this.phone, this.avatar, this.token, this.employeeNo, this.company, this.department}); + + +@override final String? userId; +@override final String? username; +@override final String? realName; +@override final String? phone; +@override final String? avatar; +@override final String? token; +@override final String? employeeNo; +@override final String? company; +@override final String? department; + +/// Create a copy of UserEntity +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$UserEntityCopyWith<_UserEntity> get copyWith => __$UserEntityCopyWithImpl<_UserEntity>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _UserEntity&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.username, username) || other.username == username)&&(identical(other.realName, realName) || other.realName == realName)&&(identical(other.phone, phone) || other.phone == phone)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.token, token) || other.token == token)&&(identical(other.employeeNo, employeeNo) || other.employeeNo == employeeNo)&&(identical(other.company, company) || other.company == company)&&(identical(other.department, department) || other.department == department)); +} + + +@override +int get hashCode => Object.hash(runtimeType,userId,username,realName,phone,avatar,token,employeeNo,company,department); + +@override +String toString() { + return 'UserEntity(userId: $userId, username: $username, realName: $realName, phone: $phone, avatar: $avatar, token: $token, employeeNo: $employeeNo, company: $company, department: $department)'; +} + + +} + +/// @nodoc +abstract mixin class _$UserEntityCopyWith<$Res> implements $UserEntityCopyWith<$Res> { + factory _$UserEntityCopyWith(_UserEntity value, $Res Function(_UserEntity) _then) = __$UserEntityCopyWithImpl; +@override @useResult +$Res call({ + String? userId, String? username, String? realName, String? phone, String? avatar, String? token, String? employeeNo, String? company, String? department +}); + + + + +} +/// @nodoc +class __$UserEntityCopyWithImpl<$Res> + implements _$UserEntityCopyWith<$Res> { + __$UserEntityCopyWithImpl(this._self, this._then); + + final _UserEntity _self; + final $Res Function(_UserEntity) _then; + +/// Create a copy of UserEntity +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? userId = freezed,Object? username = freezed,Object? realName = freezed,Object? phone = freezed,Object? avatar = freezed,Object? token = freezed,Object? employeeNo = freezed,Object? company = freezed,Object? department = freezed,}) { + return _then(_UserEntity( +userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable +as String?,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String?,realName: freezed == realName ? _self.realName : realName // ignore: cast_nullable_to_non_nullable +as String?,phone: freezed == phone ? _self.phone : phone // ignore: cast_nullable_to_non_nullable +as String?,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable +as String?,token: freezed == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String?,employeeNo: freezed == employeeNo ? _self.employeeNo : employeeNo // ignore: cast_nullable_to_non_nullable +as String?,company: freezed == company ? _self.company : company // ignore: cast_nullable_to_non_nullable +as String?,department: freezed == department ? _self.department : department // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..e96198d --- /dev/null +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,21 @@ +import 'package:sunny_mochi/features/auth/domain/entities/user_entity.dart'; + +abstract interface class AuthRepository { + Future loginWithPhone({ + required String phone, + required String code, + }); + + Future loginWithPassword({ + required String phone, + required String password, + }); + + Future logout(); + + Future getCurrentUser(); + + Future sendSmsCode(String phone); + + Future fetchUserProfile(); +} diff --git a/lib/features/auth/presentation/notifiers/auth_notifier.dart b/lib/features/auth/presentation/notifiers/auth_notifier.dart new file mode 100644 index 0000000..dde06ca --- /dev/null +++ b/lib/features/auth/presentation/notifiers/auth_notifier.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sunny_mochi/core/error/exception_mapper.dart'; +import 'package:sunny_mochi/features/auth/auth_providers.dart'; +import 'package:sunny_mochi/features/auth/domain/entities/user_entity.dart'; +import 'package:sunny_mochi/features/auth/presentation/notifiers/auth_status_provider.dart'; + +part 'auth_notifier.freezed.dart'; +part 'auth_notifier.g.dart'; + +@freezed +sealed class AuthState with _$AuthState { + const factory AuthState.initial() = AuthInitial; + const factory AuthState.loading() = AuthLoading; + const factory AuthState.authenticated(UserEntity user) = AuthAuthenticated; + const factory AuthState.unauthenticated() = AuthUnauthenticated; + const factory AuthState.error(String message) = AuthError; +} + +@riverpod +class AuthNotifier extends _$AuthNotifier { + static const _mapper = ExceptionMapper(); + + // generation 计数:防止旧 async 回调覆盖最新登录态 + int _generation = 0; + + @override + AuthState build() { + final gen = ++_generation; + unawaited(_checkAuth(gen)); + return const AuthState.initial(); + } + + Future _checkAuth(int gen) async { + final user = await ref.read(authRepositoryProvider).getCurrentUser(); + if (_generation != gen) return; + if (user != null) { + state = AuthState.authenticated(user); + ref.read(authStatusProvider).markLoggedIn(); + unawaited(refreshUserInfo()); + } else { + state = const AuthState.unauthenticated(); + ref.read(authStatusProvider).markLoggedOut(); + } + } + + Future loginWithPhone(String phone, String code) async { + _generation++; + state = const AuthState.loading(); + try { + final user = await ref + .read(authRepositoryProvider) + .loginWithPhone(phone: phone, code: code); + state = AuthState.authenticated(user); + ref.read(authStatusProvider).markLoggedIn(); + unawaited(refreshUserInfo()); + } on Object catch (e, st) { + final failure = _mapper.fromUnknown(e, st); + state = AuthState.error(failure.message); + } + } + + Future loginWithPassword(String phone, String password) async { + _generation++; + state = const AuthState.loading(); + try { + final user = await ref + .read(authRepositoryProvider) + .loginWithPassword(phone: phone, password: password); + state = AuthState.authenticated(user); + ref.read(authStatusProvider).markLoggedIn(); + unawaited(refreshUserInfo()); + } on Object catch (e, st) { + final failure = _mapper.fromUnknown(e, st); + state = AuthState.error(failure.message); + } + } + + Future logout() async { + _generation++; + state = const AuthState.unauthenticated(); + ref.read(authStatusProvider).markLoggedOut(); + unawaited(ref.read(authRepositoryProvider).logout()); + } + + /// 发送短信验证码。返回 dev/test 环境后端回显的验证码(release 为 null),供 UI 自动填入。 + Future sendSmsCode(String phone) async { + try { + return await ref.read(authRepositoryProvider).sendSmsCode(phone); + } on Object catch (e, st) { + throw _mapper.fromUnknown(e, st); + } + } + + Future refreshUserInfo() async { + final current = state; + if (current is! AuthAuthenticated) return; + final gen = _generation; + try { + final fresh = await ref.read(authRepositoryProvider).fetchUserProfile(); + if (_generation != gen) return; + state = AuthState.authenticated( + fresh.copyWith( + token: current.user.token, + userId: fresh.userId ?? current.user.userId, + ), + ); + } on Object { + // profile 拉取失败不应改变登录状态 + } + } +} diff --git a/lib/features/auth/presentation/notifiers/auth_notifier.freezed.dart b/lib/features/auth/presentation/notifiers/auth_notifier.freezed.dart new file mode 100644 index 0000000..6f2f0ee --- /dev/null +++ b/lib/features/auth/presentation/notifiers/auth_notifier.freezed.dart @@ -0,0 +1,429 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'auth_notifier.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$AuthState { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthState); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthState()'; +} + + +} + +/// @nodoc +class $AuthStateCopyWith<$Res> { +$AuthStateCopyWith(AuthState _, $Res Function(AuthState) __); +} + + +/// Adds pattern-matching-related methods to [AuthState]. +extension AuthStatePatterns on AuthState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( AuthInitial value)? initial,TResult Function( AuthLoading value)? loading,TResult Function( AuthAuthenticated value)? authenticated,TResult Function( AuthUnauthenticated value)? unauthenticated,TResult Function( AuthError value)? error,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case AuthInitial() when initial != null: +return initial(_that);case AuthLoading() when loading != null: +return loading(_that);case AuthAuthenticated() when authenticated != null: +return authenticated(_that);case AuthUnauthenticated() when unauthenticated != null: +return unauthenticated(_that);case AuthError() when error != null: +return error(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( AuthInitial value) initial,required TResult Function( AuthLoading value) loading,required TResult Function( AuthAuthenticated value) authenticated,required TResult Function( AuthUnauthenticated value) unauthenticated,required TResult Function( AuthError value) error,}){ +final _that = this; +switch (_that) { +case AuthInitial(): +return initial(_that);case AuthLoading(): +return loading(_that);case AuthAuthenticated(): +return authenticated(_that);case AuthUnauthenticated(): +return unauthenticated(_that);case AuthError(): +return error(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( AuthInitial value)? initial,TResult? Function( AuthLoading value)? loading,TResult? Function( AuthAuthenticated value)? authenticated,TResult? Function( AuthUnauthenticated value)? unauthenticated,TResult? Function( AuthError value)? error,}){ +final _that = this; +switch (_that) { +case AuthInitial() when initial != null: +return initial(_that);case AuthLoading() when loading != null: +return loading(_that);case AuthAuthenticated() when authenticated != null: +return authenticated(_that);case AuthUnauthenticated() when unauthenticated != null: +return unauthenticated(_that);case AuthError() when error != null: +return error(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? initial,TResult Function()? loading,TResult Function( UserEntity user)? authenticated,TResult Function()? unauthenticated,TResult Function( String message)? error,required TResult orElse(),}) {final _that = this; +switch (_that) { +case AuthInitial() when initial != null: +return initial();case AuthLoading() when loading != null: +return loading();case AuthAuthenticated() when authenticated != null: +return authenticated(_that.user);case AuthUnauthenticated() when unauthenticated != null: +return unauthenticated();case AuthError() when error != null: +return error(_that.message);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() initial,required TResult Function() loading,required TResult Function( UserEntity user) authenticated,required TResult Function() unauthenticated,required TResult Function( String message) error,}) {final _that = this; +switch (_that) { +case AuthInitial(): +return initial();case AuthLoading(): +return loading();case AuthAuthenticated(): +return authenticated(_that.user);case AuthUnauthenticated(): +return unauthenticated();case AuthError(): +return error(_that.message);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? initial,TResult? Function()? loading,TResult? Function( UserEntity user)? authenticated,TResult? Function()? unauthenticated,TResult? Function( String message)? error,}) {final _that = this; +switch (_that) { +case AuthInitial() when initial != null: +return initial();case AuthLoading() when loading != null: +return loading();case AuthAuthenticated() when authenticated != null: +return authenticated(_that.user);case AuthUnauthenticated() when unauthenticated != null: +return unauthenticated();case AuthError() when error != null: +return error(_that.message);case _: + return null; + +} +} + +} + +/// @nodoc + + +class AuthInitial implements AuthState { + const AuthInitial(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthInitial); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthState.initial()'; +} + + +} + + + + +/// @nodoc + + +class AuthLoading implements AuthState { + const AuthLoading(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthLoading); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthState.loading()'; +} + + +} + + + + +/// @nodoc + + +class AuthAuthenticated implements AuthState { + const AuthAuthenticated(this.user); + + + final UserEntity user; + +/// Create a copy of AuthState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AuthAuthenticatedCopyWith get copyWith => _$AuthAuthenticatedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthAuthenticated&&(identical(other.user, user) || other.user == user)); +} + + +@override +int get hashCode => Object.hash(runtimeType,user); + +@override +String toString() { + return 'AuthState.authenticated(user: $user)'; +} + + +} + +/// @nodoc +abstract mixin class $AuthAuthenticatedCopyWith<$Res> implements $AuthStateCopyWith<$Res> { + factory $AuthAuthenticatedCopyWith(AuthAuthenticated value, $Res Function(AuthAuthenticated) _then) = _$AuthAuthenticatedCopyWithImpl; +@useResult +$Res call({ + UserEntity user +}); + + +$UserEntityCopyWith<$Res> get user; + +} +/// @nodoc +class _$AuthAuthenticatedCopyWithImpl<$Res> + implements $AuthAuthenticatedCopyWith<$Res> { + _$AuthAuthenticatedCopyWithImpl(this._self, this._then); + + final AuthAuthenticated _self; + final $Res Function(AuthAuthenticated) _then; + +/// Create a copy of AuthState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? user = null,}) { + return _then(AuthAuthenticated( +null == user ? _self.user : user // ignore: cast_nullable_to_non_nullable +as UserEntity, + )); +} + +/// Create a copy of AuthState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$UserEntityCopyWith<$Res> get user { + + return $UserEntityCopyWith<$Res>(_self.user, (value) { + return _then(_self.copyWith(user: value)); + }); +} +} + +/// @nodoc + + +class AuthUnauthenticated implements AuthState { + const AuthUnauthenticated(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthUnauthenticated); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'AuthState.unauthenticated()'; +} + + +} + + + + +/// @nodoc + + +class AuthError implements AuthState { + const AuthError(this.message); + + + final String message; + +/// Create a copy of AuthState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AuthErrorCopyWith get copyWith => _$AuthErrorCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthError&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'AuthState.error(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $AuthErrorCopyWith<$Res> implements $AuthStateCopyWith<$Res> { + factory $AuthErrorCopyWith(AuthError value, $Res Function(AuthError) _then) = _$AuthErrorCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$AuthErrorCopyWithImpl<$Res> + implements $AuthErrorCopyWith<$Res> { + _$AuthErrorCopyWithImpl(this._self, this._then); + + final AuthError _self; + final $Res Function(AuthError) _then; + +/// Create a copy of AuthState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(AuthError( +null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/features/auth/presentation/notifiers/auth_notifier.g.dart b/lib/features/auth/presentation/notifiers/auth_notifier.g.dart new file mode 100644 index 0000000..ddf4650 --- /dev/null +++ b/lib/features/auth/presentation/notifiers/auth_notifier.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(AuthNotifier) +final authProvider = AuthNotifierProvider._(); + +final class AuthNotifierProvider + extends $NotifierProvider { + AuthNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'authProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$authNotifierHash(); + + @$internal + @override + AuthNotifier create() => AuthNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AuthState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$authNotifierHash() => r'40b3d6e25632667d1c904bec4fd347ac6b32848c'; + +abstract class _$AuthNotifier extends $Notifier { + AuthState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + AuthState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/auth/presentation/notifiers/auth_status_provider.dart b/lib/features/auth/presentation/notifiers/auth_status_provider.dart new file mode 100644 index 0000000..dfc8fa7 --- /dev/null +++ b/lib/features/auth/presentation/notifiers/auth_status_provider.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show ValueNotifier; +import 'package:sunny_mochi/core/observability/talker_setup.dart'; +import 'package:sunny_mochi/core/storage/secure_storage.dart' + show secureStorageProvider; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'auth_status_provider.g.dart'; + +/// 同步可读的登录态门面 — 给 GoRouter `redirect` 用。 +/// +/// 三态语义: +/// - `null` → 启动期未知(异步 storage 还没读完,redirect 应不动) +/// - `true` → 已登录 +/// - `false` → 未登录(redirect 把非 /login 的访问跳 /login) +class AuthStatusController { + AuthStatusController() : _notifier = ValueNotifier(null); + + final ValueNotifier _notifier; + + ValueNotifier get listenable => _notifier; + + bool? get value => _notifier.value; + + void markLoggedIn() { + appTalker.info('[AuthStatus] markLoggedIn'); + _notifier.value = true; + } + + void markLoggedOut() { + appTalker.info('[AuthStatus] markLoggedOut'); + _notifier.value = false; + } + + void dispose() => _notifier.dispose(); +} + +@Riverpod(keepAlive: true) +AuthStatusController authStatus(Ref ref) { + final controller = AuthStatusController(); + unawaited(_bootstrap(ref, controller)); + ref.onDispose(controller.dispose); + return controller; +} + +Future _bootstrap(Ref ref, AuthStatusController controller) async { + try { + final token = await ref.read(secureStorageProvider).getToken(); + final loggedIn = token != null && token.isNotEmpty; + if (loggedIn) { + controller.markLoggedIn(); + } else { + controller.markLoggedOut(); + } + } on Object catch (e, st) { + appTalker.warning('[AuthStatus] bootstrap 异常 → 视为未登录', e, st); + controller.markLoggedOut(); + } +} diff --git a/lib/features/auth/presentation/notifiers/auth_status_provider.g.dart b/lib/features/auth/presentation/notifiers/auth_status_provider.g.dart new file mode 100644 index 0000000..2eea027 --- /dev/null +++ b/lib/features/auth/presentation/notifiers/auth_status_provider.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_status_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(authStatus) +final authStatusProvider = AuthStatusProvider._(); + +final class AuthStatusProvider + extends + $FunctionalProvider< + AuthStatusController, + AuthStatusController, + AuthStatusController + > + with $Provider { + AuthStatusProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'authStatusProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$authStatusHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + AuthStatusController create(Ref ref) { + return authStatus(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AuthStatusController value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$authStatusHash() => r'ef142e4b7c9544c953e6da320fd43f0ee020da53'; diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart new file mode 100644 index 0000000..837a091 --- /dev/null +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sunny_mochi/core/utils/validator.dart'; +import 'package:sunny_mochi/core/widgets/app_button.dart'; +import 'package:sunny_mochi/core/widgets/app_text_field.dart'; +import 'package:sunny_mochi/core/widgets/app_toast.dart'; +import 'package:sunny_mochi/core/widgets/count_down_button.dart'; +import 'package:sunny_mochi/features/auth/presentation/notifiers/auth_notifier.dart'; + +/// 通用登录页骨架 — 手机号 + 双模式(短信验证码 / 密码)。 +/// +/// TODO: 替换为项目品牌 UI(logo / 背景 / 色彩)。 +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabCtrl; + final _phoneCtrl = TextEditingController(); + final _codeCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + bool _obscurePassword = true; + + @override + void initState() { + super.initState(); + _tabCtrl = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabCtrl.dispose(); + _phoneCtrl.dispose(); + _codeCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + bool get _isSms => _tabCtrl.index == 0; + + Future _onSendCode() async { + final phone = _phoneCtrl.text.trim(); + if (!Validator.isValidPhone(phone)) { + if (mounted) { + AppToast.show(context, '请输入有效的手机号码', type: ToastType.warning); + } + return; + } + try { + final echoCode = await ref + .read(authProvider.notifier) + .sendSmsCode(phone); + if (echoCode != null && mounted) { + _codeCtrl.text = echoCode; // dev/test 环境自动填入 + } + } on Object catch (e) { + if (mounted) { + AppToast.show(context, e.toString(), type: ToastType.error); + } + } + } + + Future _onLogin() async { + final phone = _phoneCtrl.text.trim(); + if (!Validator.isValidPhone(phone)) { + AppToast.show(context, '请输入有效的手机号码', type: ToastType.warning); + return; + } + if (_isSms) { + final code = _codeCtrl.text.trim(); + if (code.isEmpty) { + AppToast.show(context, '请输入验证码', type: ToastType.warning); + return; + } + await ref.read(authProvider.notifier).loginWithPhone(phone, code); + } else { + final pwd = _passwordCtrl.text; + if (pwd.isEmpty) { + AppToast.show(context, '请输入密码', type: ToastType.warning); + return; + } + await ref + .read(authProvider.notifier) + .loginWithPassword(phone, pwd); + } + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + final isLoading = authState is AuthLoading; + + ref.listen(authProvider, (_, next) { + if (next is AuthError && mounted) { + AppToast.show(context, next.message, type: ToastType.error); + } + }); + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + // TODO: 替换为项目 Logo + const FlutterLogo(size: 64), + const SizedBox(height: 32), + Text( + '欢迎登录', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 32), + // 手机号 + AppTextField( + controller: _phoneCtrl, + label: '手机号', + hint: '请输入手机号', + keyboardType: TextInputType.phone, + maxLength: 11, + prefixIcon: const Icon(Icons.phone_outlined), + ), + const SizedBox(height: 16), + // 模式切换 Tab + TabBar( + controller: _tabCtrl, + onTap: (_) => setState(() {}), + tabs: const [ + Tab(text: '短信验证码'), + Tab(text: '密码登录'), + ], + ), + const SizedBox(height: 16), + // 验证码 / 密码输入 + if (_isSms) + Row( + children: [ + Expanded( + child: AppTextField( + controller: _codeCtrl, + label: '验证码', + hint: '请输入验证码', + keyboardType: TextInputType.number, + maxLength: 6, + ), + ), + const SizedBox(width: 12), + CountDownButton(onSend: _onSendCode), + ], + ) + else + AppTextField( + controller: _passwordCtrl, + label: '密码', + hint: '请输入密码', + obscureText: _obscurePassword, + suffix: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + ), + const SizedBox(height: 32), + AppButton( + label: '登录', + onPressed: _onLogin, + loading: isLoading, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/dev_panel/presentation/pages/dev_panel_page.dart b/lib/features/dev_panel/presentation/pages/dev_panel_page.dart new file mode 100644 index 0000000..6f6a5f3 --- /dev/null +++ b/lib/features/dev_panel/presentation/pages/dev_panel_page.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:sunny_mochi/core/observability/talker_setup.dart'; +import 'package:talker_flutter/talker_flutter.dart'; + +/// 开发者面板(TalkerScreen)— 仅在 Env.enableDevPanel 为真时可路由进入。 +/// +/// 提供日志查看 / 网络请求审阅 / 错误回放等开发期可观测性能力。 +/// 不要在 Release 主线包暴露入口(合规:用户日志可能含 PII)。 +class DevPanelPage extends StatelessWidget { + const DevPanelPage({super.key}); + + @override + Widget build(BuildContext context) { + return TalkerScreen(talker: appTalker); + } +} diff --git a/lib/features/error_report/data/device_info_service.dart b/lib/features/error_report/data/device_info_service.dart new file mode 100644 index 0000000..d40bd3e --- /dev/null +++ b/lib/features/error_report/data/device_info_service.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'device_info_service.g.dart'; + +class DeviceSnapshot { + const DeviceSnapshot({ + required this.deviceModel, + required this.osVersion, + required this.appVersion, + required this.appBuild, + }); + + final String deviceModel; + final String osVersion; + final String appVersion; + final String appBuild; + + Map toJson() => { + 'deviceModel': deviceModel, + 'osVersion': osVersion, + 'appVersion': appVersion, + 'appBuild': appBuild, + }; +} + +@riverpod +DeviceInfoService deviceInfoService(Ref ref) => const DeviceInfoService(); + +class DeviceInfoService { + const DeviceInfoService(); + + DeviceInfoPlugin get _plugin => DeviceInfoPlugin(); + + Future snapshot() async { + final pkg = await PackageInfo.fromPlatform(); + + if (kIsWeb) { + return DeviceSnapshot( + deviceModel: 'Web', + osVersion: 'Web', + appVersion: pkg.version, + appBuild: pkg.buildNumber, + ); + } + + if (Platform.isAndroid) { + final info = await _plugin.androidInfo; + return DeviceSnapshot( + deviceModel: '${info.manufacturer} ${info.model}', + osVersion: 'Android ${info.version.release}', + appVersion: pkg.version, + appBuild: pkg.buildNumber, + ); + } + + if (Platform.isIOS) { + final info = await _plugin.iosInfo; + return DeviceSnapshot( + deviceModel: info.model, + osVersion: '${info.systemName} ${info.systemVersion}', + appVersion: pkg.version, + appBuild: pkg.buildNumber, + ); + } + + return DeviceSnapshot( + deviceModel: Platform.operatingSystem, + osVersion: Platform.operatingSystemVersion, + appVersion: pkg.version, + appBuild: pkg.buildNumber, + ); + } +} diff --git a/lib/features/error_report/data/device_info_service.g.dart b/lib/features/error_report/data/device_info_service.g.dart new file mode 100644 index 0000000..065ff9c --- /dev/null +++ b/lib/features/error_report/data/device_info_service.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'device_info_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(deviceInfoService) +final deviceInfoServiceProvider = DeviceInfoServiceProvider._(); + +final class DeviceInfoServiceProvider + extends + $FunctionalProvider< + DeviceInfoService, + DeviceInfoService, + DeviceInfoService + > + with $Provider { + DeviceInfoServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'deviceInfoServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$deviceInfoServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + DeviceInfoService create(Ref ref) { + return deviceInfoService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(DeviceInfoService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$deviceInfoServiceHash() => r'a529d8f8673a6207e1c73bc69db4555857056c57'; diff --git a/lib/features/error_report/data/error_logger.dart b/lib/features/error_report/data/error_logger.dart new file mode 100644 index 0000000..9c51c6b --- /dev/null +++ b/lib/features/error_report/data/error_logger.dart @@ -0,0 +1,157 @@ +import 'package:drift/drift.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sunny_mochi/core/error/failures.dart'; +import 'package:sunny_mochi/core/storage/app_database.dart'; +import 'package:sunny_mochi/features/error_report/data/device_info_service.dart'; + +part 'error_logger.g.dart'; + +enum ErrorKind { + network, + server, + auth, + cache, + unknown; + + String get displayLabel => switch (this) { + ErrorKind.network => '网络错误', + ErrorKind.server => '接口错误', + ErrorKind.auth => '认证错误', + ErrorKind.cache => '本地错误', + ErrorKind.unknown => '未知错误', + }; + + static ErrorKind from(String name) => + ErrorKind.values.firstWhere((k) => k.name == name, + orElse: () => ErrorKind.unknown); +} + +class ErrorEntry { + const ErrorEntry({ + required this.id, + required this.kind, + required this.message, + required this.displayMessage, + required this.occurredAt, + required this.reported, + this.code, + this.statusCode, + }); + + factory ErrorEntry.fromRow(ErrorLogRow row) => ErrorEntry( + id: row.id, + kind: ErrorKind.from(row.kind), + message: row.message, + displayMessage: row.displayMessage, + code: row.code, + statusCode: row.statusCode, + occurredAt: row.occurredAt, + reported: row.reported, + ); + + final int id; + final ErrorKind kind; + final String message; + final String displayMessage; + final String? code; + final int? statusCode; + final DateTime occurredAt; + final bool reported; +} + +extension _FailureKind on Failure { + ErrorKind get kind => switch (this) { + NetworkFailure() => ErrorKind.network, + ServerFailure() => ErrorKind.server, + AuthFailure() => ErrorKind.auth, + CacheFailure() => ErrorKind.cache, + UnknownFailure() => ErrorKind.unknown, + }; +} + +@Riverpod(keepAlive: true) +ErrorLogger errorLogger(Ref ref) => ErrorLogger( + ref.watch(appDatabaseProvider), + ref.watch(deviceInfoServiceProvider), + ); + +class ErrorLogger { + ErrorLogger(this._db, this._deviceService); + + final AppDatabase _db; + final DeviceInfoService _deviceService; + DeviceSnapshot? _cachedDevice; + static const maxEntries = 200; + static const maxRetentionDays = 30; + static const maxBatchUpload = 50; + + Future _device() async => + _cachedDevice ??= await _deviceService.snapshot(); + + Future log(Failure failure) async { + final device = await _device(); + await _db.into(_db.errorLogs).insert( + ErrorLogsCompanion.insert( + kind: failure.kind.name, + message: failure.message, + displayMessage: failure.userMessage, + code: Value(failure is ServerFailure ? failure.code : null), + statusCode: Value( + failure is ServerFailure ? failure.statusCode : null, + ), + occurredAt: DateTime.now(), + deviceModel: Value(device.deviceModel), + osVersion: Value(device.osVersion), + appVersion: Value(device.appVersion), + appBuild: Value(device.appBuild), + ), + ); + await _prune(); + } + + Future _prune() async { + final ids = await (_db.selectOnly(_db.errorLogs) + ..addColumns([_db.errorLogs.id]) + ..orderBy([OrderingTerm.desc(_db.errorLogs.occurredAt)])) + .map((r) => r.read(_db.errorLogs.id)!) + .get(); + + if (ids.length > maxEntries) { + final toDelete = ids.skip(maxEntries).toList(); + await (_db.delete(_db.errorLogs) + ..where((t) => t.id.isIn(toDelete))) + .go(); + } + } + + Stream> watchAll() => + (_db.select(_db.errorLogs) + ..orderBy([(t) => OrderingTerm.desc(t.occurredAt)]) + ..limit(100)) + .watch() + .map((rows) => rows.map(ErrorEntry.fromRow).toList()); + + Future pruneOld() async { + final cutoff = DateTime.now().subtract( + const Duration(days: maxRetentionDays), + ); + await (_db.delete(_db.errorLogs) + ..where((t) => t.occurredAt.isSmallerOrEqualValue(cutoff))) + .go(); + } + + Future> getUnreported() => + (_db.select(_db.errorLogs) + ..where((t) => t.reported.equals(false)) + ..orderBy([(t) => OrderingTerm.desc(t.occurredAt)]) + ..limit(maxBatchUpload)) + .get(); + + Future markReported(List ids) async { + if (ids.isEmpty) return; + await (_db.update(_db.errorLogs)..where((t) => t.id.isIn(ids))) + .write(const ErrorLogsCompanion(reported: Value(true))); + } + + Future clearAll() => _db.delete(_db.errorLogs).go(); +} diff --git a/lib/features/error_report/data/error_logger.g.dart b/lib/features/error_report/data/error_logger.g.dart new file mode 100644 index 0000000..c627446 --- /dev/null +++ b/lib/features/error_report/data/error_logger.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'error_logger.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(errorLogger) +final errorLoggerProvider = ErrorLoggerProvider._(); + +final class ErrorLoggerProvider + extends $FunctionalProvider + with $Provider { + ErrorLoggerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'errorLoggerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$errorLoggerHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + ErrorLogger create(Ref ref) { + return errorLogger(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ErrorLogger value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$errorLoggerHash() => r'0486bd08edcaa19da8c2242f759ab8434dfad93e'; diff --git a/lib/features/error_report/data/error_report_datasource.dart b/lib/features/error_report/data/error_report_datasource.dart new file mode 100644 index 0000000..754b056 --- /dev/null +++ b/lib/features/error_report/data/error_report_datasource.dart @@ -0,0 +1,42 @@ +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sunny_mochi/core/config/api_paths.dart'; +import 'package:sunny_mochi/core/network/api_response.dart'; +import 'package:sunny_mochi/core/network/dio_client.dart'; +import 'package:sunny_mochi/core/storage/app_database.dart'; + +part 'error_report_datasource.g.dart'; + +@riverpod +ErrorReportDatasource errorReportDatasource(Ref ref) => + ErrorReportDatasource(ref.watch(dioClientProvider)); + +class ErrorReportDatasource { + const ErrorReportDatasource(this._dio); + final Dio _dio; + + Future sendReport(List logs) async { + final resp = await _dio.post>( + ApiPaths.errorReport, + data: { + 'entries': logs + .map( + (l) => { + 'kind': l.kind, + 'message': l.message, + 'displayMessage': l.displayMessage, + if (l.code != null) 'code': l.code, + if (l.statusCode != null) 'statusCode': l.statusCode, + 'occurredAt': l.occurredAt.toUtc().toIso8601String(), + if (l.deviceModel != null) 'deviceModel': l.deviceModel, + if (l.osVersion != null) 'osVersion': l.osVersion, + if (l.appVersion != null) 'appVersion': l.appVersion, + if (l.appBuild != null) 'appBuild': l.appBuild, + }, + ) + .toList(), + }, + ); + parseEnvelope(resp.data, (j) => j).unwrapVoid(); + } +} diff --git a/lib/features/error_report/data/error_report_datasource.g.dart b/lib/features/error_report/data/error_report_datasource.g.dart new file mode 100644 index 0000000..01d27d0 --- /dev/null +++ b/lib/features/error_report/data/error_report_datasource.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'error_report_datasource.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(errorReportDatasource) +final errorReportDatasourceProvider = ErrorReportDatasourceProvider._(); + +final class ErrorReportDatasourceProvider + extends + $FunctionalProvider< + ErrorReportDatasource, + ErrorReportDatasource, + ErrorReportDatasource + > + with $Provider { + ErrorReportDatasourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'errorReportDatasourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$errorReportDatasourceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + ErrorReportDatasource create(Ref ref) { + return errorReportDatasource(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ErrorReportDatasource value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$errorReportDatasourceHash() => + r'2a3077bdcb8d59bad619607dcea7dea278b382a0'; diff --git a/lib/features/error_report/presentation/error_report_notifier.dart b/lib/features/error_report/presentation/error_report_notifier.dart new file mode 100644 index 0000000..76d9a30 --- /dev/null +++ b/lib/features/error_report/presentation/error_report_notifier.dart @@ -0,0 +1,32 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sunny_mochi/features/error_report/data/error_logger.dart'; +import 'package:sunny_mochi/features/error_report/data/error_report_datasource.dart'; + +part 'error_report_notifier.g.dart'; + +@riverpod +Stream> errorLogs(Ref ref) => + ref.watch(errorLoggerProvider).watchAll(); + +@riverpod +class ErrorReportSender extends _$ErrorReportSender { + @override + AsyncValue build() => const AsyncData(true); + + Future send() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final logger = ref.read(errorLoggerProvider); + final logs = await logger.getUnreported(); + if (logs.isEmpty) return true; + await ref.read(errorReportDatasourceProvider).sendReport(logs); + await logger.markReported(logs.map((l) => l.id).toList()); + return true; + }); + } + + Future clearAll() async { + await ref.read(errorLoggerProvider).clearAll(); + state = const AsyncData(true); + } +} diff --git a/lib/features/error_report/presentation/error_report_notifier.g.dart b/lib/features/error_report/presentation/error_report_notifier.g.dart new file mode 100644 index 0000000..4656a79 --- /dev/null +++ b/lib/features/error_report/presentation/error_report_notifier.g.dart @@ -0,0 +1,101 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'error_report_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(errorLogs) +final errorLogsProvider = ErrorLogsProvider._(); + +final class ErrorLogsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + Stream> + > + with $FutureModifier>, $StreamProvider> { + ErrorLogsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'errorLogsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$errorLogsHash(); + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + return errorLogs(ref); + } +} + +String _$errorLogsHash() => r'16329046d2b056a007f56835c40e43664ba87533'; + +@ProviderFor(ErrorReportSender) +final errorReportSenderProvider = ErrorReportSenderProvider._(); + +final class ErrorReportSenderProvider + extends $NotifierProvider> { + ErrorReportSenderProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'errorReportSenderProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$errorReportSenderHash(); + + @$internal + @override + ErrorReportSender create() => ErrorReportSender(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AsyncValue value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$errorReportSenderHash() => r'12c474e98289540bc9307911a2e40049ef49b449'; + +abstract class _$ErrorReportSender extends $Notifier> { + AsyncValue build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, AsyncValue>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, AsyncValue>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/error_report/presentation/error_report_page.dart b/lib/features/error_report/presentation/error_report_page.dart new file mode 100644 index 0000000..51fd05f --- /dev/null +++ b/lib/features/error_report/presentation/error_report_page.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:sunny_mochi/core/extensions/context_x.dart'; +import 'package:sunny_mochi/core/widgets/app_toast.dart'; +import 'package:sunny_mochi/features/error_report/data/error_logger.dart'; +import 'package:sunny_mochi/features/error_report/presentation/error_report_notifier.dart'; + +class ErrorReportPage extends ConsumerWidget { + const ErrorReportPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final logsAsync = ref.watch(errorLogsProvider); + final sender = ref.watch(errorReportSenderProvider); + + ref.listen(errorReportSenderProvider, (prev, next) { + if (next is AsyncError) { + context.showError(ref, next.error ?? Exception('未知错误'), + next.stackTrace ?? StackTrace.empty); + } else if (next is AsyncData && prev is AsyncLoading) { + context.showToast('上报成功,感谢您的反馈', type: ToastType.success); + } + }); + + return Scaffold( + appBar: AppBar( + title: const Text('错误报告'), + actions: [ + TextButton( + onPressed: sender.isLoading + ? null + : () => + ref.read(errorReportSenderProvider.notifier).clearAll(), + child: Text( + '清除', + style: TextStyle( + color: sender.isLoading + ? const Color(0xFFAEAEB2) + : const Color(0xFFFF3B30), + fontSize: 15.sp, + ), + ), + ), + ], + ), + body: logsAsync.when( + loading: () => + const Center(child: CircularProgressIndicator.adaptive()), + error: (e, _) => Center(child: Text('$e')), + data: (logs) => + logs.isEmpty ? const _EmptyState() : _LogList(logs: logs), + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 16.h), + child: FilledButton( + onPressed: sender.isLoading + ? null + : () => ref.read(errorReportSenderProvider.notifier).send(), + style: FilledButton.styleFrom(minimumSize: Size.fromHeight(50.h)), + child: sender.isLoading + ? SizedBox( + width: 20.w, + height: 20.w, + child: const CircularProgressIndicator.adaptive( + strokeWidth: 2), + ) + : const Text('一键发送错误报告'), + ), + ), + ), + ); + } +} + +class _LogList extends StatelessWidget { + const _LogList({required this.logs}); + final List logs; + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: EdgeInsets.symmetric(vertical: 12.h), + itemCount: logs.length, + separatorBuilder: (_, _) => Divider( + height: 1, + indent: 16.w, + endIndent: 16.w, + color: const Color(0xFFE5E5EA), + ), + itemBuilder: (_, i) => _LogTile(log: logs[i]), + ); + } +} + +class _LogTile extends StatelessWidget { + const _LogTile({required this.log}); + final ErrorEntry log; + + @override + Widget build(BuildContext context) { + final (color, bg) = switch (log.kind) { + ErrorKind.network => + (const Color(0xFFFF9F0A), const Color(0xFFFFF3E0)), + ErrorKind.server => + (const Color(0xFFFF3B30), const Color(0xFFFFEBEB)), + ErrorKind.auth => + (const Color(0xFF6B63D9), const Color(0xFFF0EFFF)), + ErrorKind.cache => + (const Color(0xFF34C759), const Color(0xFFE8F8ED)), + ErrorKind.unknown => + (const Color(0xFF8E8E93), const Color(0xFFF2F2F7)), + }; + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(6.r), + ), + child: Text( + log.kind.displayLabel, + style: TextStyle( + color: color, + fontSize: 12.sp, + fontWeight: FontWeight.w600), + ), + ), + SizedBox(width: 10.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(log.displayMessage, + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF252535), + fontWeight: FontWeight.w500)), + SizedBox(height: 4.h), + Text(log.message, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, color: const Color(0xFF8E8E93))), + SizedBox(height: 4.h), + Text(_fmt(log.occurredAt), + style: TextStyle( + fontSize: 12.sp, color: const Color(0xFFAEAEB2))), + ], + ), + ), + if (log.reported) + Padding( + padding: EdgeInsets.only(left: 8.w), + child: Icon(Icons.check_circle_outline_rounded, + size: 16.sp, color: const Color(0xFF34C759)), + ), + ], + ), + ); + } + + String _fmt(DateTime dt) { + final t = dt.toLocal(); + return '${t.year}-${t.month.toString().padLeft(2, '0')}-' + '${t.day.toString().padLeft(2, '0')} ' + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline_rounded, + size: 64.sp, color: const Color(0xFF34C759)), + SizedBox(height: 16.h), + Text('暂无错误记录', + style: TextStyle(fontSize: 16.sp, color: const Color(0xFF8E8E93))), + SizedBox(height: 8.h), + Text('应用运行正常', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFFAEAEB2))), + ], + ), + ); + } +} diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json new file mode 100644 index 0000000..4c4af19 --- /dev/null +++ b/lib/i18n/en.i18n.json @@ -0,0 +1,42 @@ +{ + "tab": { + "home": "Home", + "mine": "Mine" + }, + "button": { + "confirm": "Confirm", + "cancel": "Cancel", + "retry": "Retry", + "submit": "Submit", + "save": "Save", + "edit": "Edit", + "delete": "Delete", + "back": "Back", + "close": "Close", + "login": "Login", + "logout": "Logout" + }, + "error": { + "unknown": "Something went wrong, please try again", + "network": "Network connection failed", + "timeout": "Request timed out", + "server": "Server error, please try again", + "auth": "Session expired, please login again", + "empty_input": "Please fill in required fields", + "invalid_phone": "Please enter a valid phone number" + }, + "empty": { + "default": "No data", + "list": "List is empty" + }, + "auth": { + "login_title": "Welcome", + "phone_hint": "Enter phone number", + "code_hint": "Enter verification code", + "password_hint": "Enter password", + "send_code": "Send Code", + "resend_code": "Resend", + "sms_tab": "SMS Code", + "pwd_tab": "Password" + } +} diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart new file mode 100644 index 0000000..f5c22ae --- /dev/null +++ b/lib/i18n/strings.g.dart @@ -0,0 +1,183 @@ +/// Generated file. Do not edit. +/// +/// Source: lib/i18n +/// To regenerate, run: `dart run slang` +/// +/// Locales: 2 +/// Strings: 60 (30 per locale) +/// +/// Built on 2026-05-14 at 04:33 UTC + +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import +// dart format off + +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; +import 'package:slang_flutter/slang_flutter.dart'; +export 'package:slang_flutter/slang_flutter.dart'; + +import 'strings_en.g.dart' deferred as l_en; +part 'strings_zh_CN.g.dart'; + +/// Supported locales. +/// +/// Usage: +/// - LocaleSettings.setLocale(AppLocale.zhCn) // set locale +/// - Locale locale = AppLocale.zhCn.flutterLocale // get flutter locale from enum +/// - if (LocaleSettings.currentLocale == AppLocale.zhCn) // locale check +enum AppLocale with BaseAppLocale { + zhCn(languageCode: 'zh', countryCode: 'CN'), + en(languageCode: 'en'); + + const AppLocale({ + required this.languageCode, + this.scriptCode, // ignore: unused_element, unused_element_parameter + this.countryCode, // ignore: unused_element, unused_element_parameter + }); + + @override final String languageCode; + @override final String? scriptCode; + @override final String? countryCode; + + @override + Future build({ + Map? overrides, + PluralResolver? cardinalResolver, + PluralResolver? ordinalResolver, + }) async { + switch (this) { + case AppLocale.zhCn: + return TranslationsZhCn( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + case AppLocale.en: + await l_en.loadLibrary(); + return l_en.TranslationsEn( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + } + } + + @override + Translations buildSync({ + Map? overrides, + PluralResolver? cardinalResolver, + PluralResolver? ordinalResolver, + }) { + switch (this) { + case AppLocale.zhCn: + return TranslationsZhCn( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + case AppLocale.en: + return l_en.TranslationsEn( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + } + } + + /// Gets current instance managed by [LocaleSettings]. + Translations get translations => LocaleSettings.instance.getTranslations(this); +} + +/// Method A: Simple +/// +/// No rebuild after locale change. +/// Translation happens during initialization of the widget (call of t). +/// Configurable via 'translate_var'. +/// +/// Usage: +/// String a = t.someKey.anotherKey; +/// String b = t['someKey.anotherKey']; // Only for edge cases! +Translations get t => LocaleSettings.instance.currentTranslations; + +/// Method B: Advanced +/// +/// All widgets using this method will trigger a rebuild when locale changes. +/// Use this if you have e.g. a settings page where the user can select the locale during runtime. +/// +/// Step 1: +/// wrap your App with +/// TranslationProvider( +/// child: MyApp() +/// ); +/// +/// Step 2: +/// final t = Translations.of(context); // Get t variable. +/// String a = t.someKey.anotherKey; // Use t variable. +/// String b = t['someKey.anotherKey']; // Only for edge cases! +class TranslationProvider extends BaseTranslationProvider { + TranslationProvider({required super.child}) : super(settings: LocaleSettings.instance); + + static InheritedLocaleData of(BuildContext context) => InheritedLocaleData.of(context); +} + +/// Method B shorthand via [BuildContext] extension method. +/// Configurable via 'translate_var'. +/// +/// Usage (e.g. in a widget's build method): +/// context.t.someKey.anotherKey +extension BuildContextTranslationsExtension on BuildContext { + Translations get t => TranslationProvider.of(this).translations; +} + +/// Manages all translation instances and the current locale +class LocaleSettings extends BaseFlutterLocaleSettings { + LocaleSettings._() : super( + utils: AppLocaleUtils.instance, + lazy: true, + ); + + static final instance = LocaleSettings._(); + + // static aliases (checkout base methods for documentation) + static AppLocale get currentLocale => instance.currentLocale; + static Stream getLocaleStream() => instance.getLocaleStream(); + static Future setLocale(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocale(locale, listenToDeviceLocale: listenToDeviceLocale); + static Future setLocaleRaw(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRaw(rawLocale, listenToDeviceLocale: listenToDeviceLocale); + static Future useDeviceLocale() => instance.useDeviceLocale(); + static Future setPluralResolver({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolver( + language: language, + locale: locale, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + + // synchronous versions + static AppLocale setLocaleSync(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocaleSync(locale, listenToDeviceLocale: listenToDeviceLocale); + static AppLocale setLocaleRawSync(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRawSync(rawLocale, listenToDeviceLocale: listenToDeviceLocale); + static AppLocale useDeviceLocaleSync() => instance.useDeviceLocaleSync(); + static void setPluralResolverSync({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolverSync( + language: language, + locale: locale, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); +} + +/// Provides utility functions without any side effects. +class AppLocaleUtils extends BaseAppLocaleUtils { + AppLocaleUtils._() : super( + baseLocale: AppLocale.zhCn, + locales: AppLocale.values, + ); + + static final instance = AppLocaleUtils._(); + + // static aliases (checkout base methods for documentation) + static AppLocale parse(String rawLocale) => instance.parse(rawLocale); + static AppLocale parseLocaleParts({required String languageCode, String? scriptCode, String? countryCode}) => instance.parseLocaleParts(languageCode: languageCode, scriptCode: scriptCode, countryCode: countryCode); + static AppLocale findDeviceLocale() => instance.findDeviceLocale(); + static List get supportedLocales => instance.supportedLocales; + static List get supportedLocalesRaw => instance.supportedLocalesRaw; +} diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart new file mode 100644 index 0000000..196ee12 --- /dev/null +++ b/lib/i18n/strings_en.g.dart @@ -0,0 +1,165 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import +// dart format off + +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; +import 'strings.g.dart'; + +// Path: +class TranslationsEn extends Translations with BaseTranslations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + TranslationsEn({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = meta ?? TranslationMetadata( + locale: AppLocale.en, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); + + late final TranslationsEn _root = this; // ignore: unused_field + + @override + TranslationsEn $copyWith({TranslationMetadata? meta}) => TranslationsEn(meta: meta ?? this.$meta); + + // Translations + @override late final _TranslationsTabEn tab = _TranslationsTabEn._(_root); + @override late final _TranslationsButtonEn button = _TranslationsButtonEn._(_root); + @override late final _TranslationsErrorEn error = _TranslationsErrorEn._(_root); + @override late final _TranslationsEmptyEn empty = _TranslationsEmptyEn._(_root); + @override late final _TranslationsAuthEn auth = _TranslationsAuthEn._(_root); +} + +// Path: tab +class _TranslationsTabEn extends TranslationsTabZhCn { + _TranslationsTabEn._(TranslationsEn root) : this._root = root, super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override String get home => 'Home'; + @override String get mine => 'Mine'; +} + +// Path: button +class _TranslationsButtonEn extends TranslationsButtonZhCn { + _TranslationsButtonEn._(TranslationsEn root) : this._root = root, super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override String get confirm => 'Confirm'; + @override String get cancel => 'Cancel'; + @override String get retry => 'Retry'; + @override String get submit => 'Submit'; + @override String get save => 'Save'; + @override String get edit => 'Edit'; + @override String get delete => 'Delete'; + @override String get back => 'Back'; + @override String get close => 'Close'; + @override String get login => 'Login'; + @override String get logout => 'Logout'; +} + +// Path: error +class _TranslationsErrorEn extends TranslationsErrorZhCn { + _TranslationsErrorEn._(TranslationsEn root) : this._root = root, super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override String get unknown => 'Something went wrong, please try again'; + @override String get network => 'Network connection failed'; + @override String get timeout => 'Request timed out'; + @override String get server => 'Server error, please try again'; + @override String get auth => 'Session expired, please login again'; + @override String get empty_input => 'Please fill in required fields'; + @override String get invalid_phone => 'Please enter a valid phone number'; +} + +// Path: empty +class _TranslationsEmptyEn extends TranslationsEmptyZhCn { + _TranslationsEmptyEn._(TranslationsEn root) : this._root = root, super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override String get kDefault => 'No data'; + @override String get list => 'List is empty'; +} + +// Path: auth +class _TranslationsAuthEn extends TranslationsAuthZhCn { + _TranslationsAuthEn._(TranslationsEn root) : this._root = root, super.internal(root); + + final TranslationsEn _root; // ignore: unused_field + + // Translations + @override String get login_title => 'Welcome'; + @override String get phone_hint => 'Enter phone number'; + @override String get code_hint => 'Enter verification code'; + @override String get password_hint => 'Enter password'; + @override String get send_code => 'Send Code'; + @override String get resend_code => 'Resend'; + @override String get sms_tab => 'SMS Code'; + @override String get pwd_tab => 'Password'; +} + +/// The flat map containing all translations for locale . +/// Only for edge cases! For simple maps, use the map function of this library. +/// +/// The Dart AOT compiler has issues with very large switch statements, +/// so the map is split into smaller functions (512 entries each). +extension on TranslationsEn { + dynamic _flatMapFunction(String path) { + return switch (path) { + 'tab.home' => 'Home', + 'tab.mine' => 'Mine', + 'button.confirm' => 'Confirm', + 'button.cancel' => 'Cancel', + 'button.retry' => 'Retry', + 'button.submit' => 'Submit', + 'button.save' => 'Save', + 'button.edit' => 'Edit', + 'button.delete' => 'Delete', + 'button.back' => 'Back', + 'button.close' => 'Close', + 'button.login' => 'Login', + 'button.logout' => 'Logout', + 'error.unknown' => 'Something went wrong, please try again', + 'error.network' => 'Network connection failed', + 'error.timeout' => 'Request timed out', + 'error.server' => 'Server error, please try again', + 'error.auth' => 'Session expired, please login again', + 'error.empty_input' => 'Please fill in required fields', + 'error.invalid_phone' => 'Please enter a valid phone number', + 'empty.kDefault' => 'No data', + 'empty.list' => 'List is empty', + 'auth.login_title' => 'Welcome', + 'auth.phone_hint' => 'Enter phone number', + 'auth.code_hint' => 'Enter verification code', + 'auth.password_hint' => 'Enter password', + 'auth.send_code' => 'Send Code', + 'auth.resend_code' => 'Resend', + 'auth.sms_tab' => 'SMS Code', + 'auth.pwd_tab' => 'Password', + _ => null, + }; + } +} diff --git a/lib/i18n/strings_zh_CN.g.dart b/lib/i18n/strings_zh_CN.g.dart new file mode 100644 index 0000000..d63b22c --- /dev/null +++ b/lib/i18n/strings_zh_CN.g.dart @@ -0,0 +1,226 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import +// dart format off + +part of 'strings.g.dart'; + +// Path: +typedef TranslationsZhCn = Translations; // ignore: unused_element +class Translations with BaseTranslations { + /// Returns the current translations of the given [context]. + /// + /// Usage: + /// final t = Translations.of(context); + static Translations of(BuildContext context) => InheritedLocaleData.of(context).translations; + + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + Translations({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = meta ?? TranslationMetadata( + locale: AppLocale.zhCn, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ) { + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + dynamic operator[](String key) => $meta.getTranslation(key); + + late final Translations _root = this; // ignore: unused_field + + Translations $copyWith({TranslationMetadata? meta}) => Translations(meta: meta ?? this.$meta); + + // Translations + late final TranslationsTabZhCn tab = TranslationsTabZhCn.internal(_root); + late final TranslationsButtonZhCn button = TranslationsButtonZhCn.internal(_root); + late final TranslationsErrorZhCn error = TranslationsErrorZhCn.internal(_root); + late final TranslationsEmptyZhCn empty = TranslationsEmptyZhCn.internal(_root); + late final TranslationsAuthZhCn auth = TranslationsAuthZhCn.internal(_root); +} + +// Path: tab +class TranslationsTabZhCn { + TranslationsTabZhCn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-CN: '首页' + String get home => '首页'; + + /// zh-CN: '我的' + String get mine => '我的'; +} + +// Path: button +class TranslationsButtonZhCn { + TranslationsButtonZhCn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-CN: '确定' + String get confirm => '确定'; + + /// zh-CN: '取消' + String get cancel => '取消'; + + /// zh-CN: '重试' + String get retry => '重试'; + + /// zh-CN: '提交' + String get submit => '提交'; + + /// zh-CN: '保存' + String get save => '保存'; + + /// zh-CN: '编辑' + String get edit => '编辑'; + + /// zh-CN: '删除' + String get delete => '删除'; + + /// zh-CN: '返回' + String get back => '返回'; + + /// zh-CN: '关闭' + String get close => '关闭'; + + /// zh-CN: '登录' + String get login => '登录'; + + /// zh-CN: '退出登录' + String get logout => '退出登录'; +} + +// Path: error +class TranslationsErrorZhCn { + TranslationsErrorZhCn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-CN: '出现了一点问题,请稍后重试' + String get unknown => '出现了一点问题,请稍后重试'; + + /// zh-CN: '网络连接失败,请检查网络' + String get network => '网络连接失败,请检查网络'; + + /// zh-CN: '请求超时,请稍后重试' + String get timeout => '请求超时,请稍后重试'; + + /// zh-CN: '服务器开小差了,请稍后重试' + String get server => '服务器开小差了,请稍后重试'; + + /// zh-CN: '登录已过期,请重新登录' + String get auth => '登录已过期,请重新登录'; + + /// zh-CN: '请填写必填项' + String get empty_input => '请填写必填项'; + + /// zh-CN: '请输入有效的手机号' + String get invalid_phone => '请输入有效的手机号'; +} + +// Path: empty +class TranslationsEmptyZhCn { + TranslationsEmptyZhCn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-CN: '暂无数据' + String get kDefault => '暂无数据'; + + /// zh-CN: '列表为空' + String get list => '列表为空'; +} + +// Path: auth +class TranslationsAuthZhCn { + TranslationsAuthZhCn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-CN: '欢迎登录' + String get login_title => '欢迎登录'; + + /// zh-CN: '请输入手机号' + String get phone_hint => '请输入手机号'; + + /// zh-CN: '请输入验证码' + String get code_hint => '请输入验证码'; + + /// zh-CN: '请输入密码' + String get password_hint => '请输入密码'; + + /// zh-CN: '发送验证码' + String get send_code => '发送验证码'; + + /// zh-CN: '重新发送' + String get resend_code => '重新发送'; + + /// zh-CN: '短信验证码' + String get sms_tab => '短信验证码'; + + /// zh-CN: '密码登录' + String get pwd_tab => '密码登录'; +} + +/// The flat map containing all translations for locale . +/// Only for edge cases! For simple maps, use the map function of this library. +/// +/// The Dart AOT compiler has issues with very large switch statements, +/// so the map is split into smaller functions (512 entries each). +extension on Translations { + dynamic _flatMapFunction(String path) { + return switch (path) { + 'tab.home' => '首页', + 'tab.mine' => '我的', + 'button.confirm' => '确定', + 'button.cancel' => '取消', + 'button.retry' => '重试', + 'button.submit' => '提交', + 'button.save' => '保存', + 'button.edit' => '编辑', + 'button.delete' => '删除', + 'button.back' => '返回', + 'button.close' => '关闭', + 'button.login' => '登录', + 'button.logout' => '退出登录', + 'error.unknown' => '出现了一点问题,请稍后重试', + 'error.network' => '网络连接失败,请检查网络', + 'error.timeout' => '请求超时,请稍后重试', + 'error.server' => '服务器开小差了,请稍后重试', + 'error.auth' => '登录已过期,请重新登录', + 'error.empty_input' => '请填写必填项', + 'error.invalid_phone' => '请输入有效的手机号', + 'empty.kDefault' => '暂无数据', + 'empty.list' => '列表为空', + 'auth.login_title' => '欢迎登录', + 'auth.phone_hint' => '请输入手机号', + 'auth.code_hint' => '请输入验证码', + 'auth.password_hint' => '请输入密码', + 'auth.send_code' => '发送验证码', + 'auth.resend_code' => '重新发送', + 'auth.sms_tab' => '短信验证码', + 'auth.pwd_tab' => '密码登录', + _ => null, + }; + } +} diff --git a/lib/i18n/zh-CN.i18n.json b/lib/i18n/zh-CN.i18n.json new file mode 100644 index 0000000..0128e06 --- /dev/null +++ b/lib/i18n/zh-CN.i18n.json @@ -0,0 +1,42 @@ +{ + "tab": { + "home": "首页", + "mine": "我的" + }, + "button": { + "confirm": "确定", + "cancel": "取消", + "retry": "重试", + "submit": "提交", + "save": "保存", + "edit": "编辑", + "delete": "删除", + "back": "返回", + "close": "关闭", + "login": "登录", + "logout": "退出登录" + }, + "error": { + "unknown": "出现了一点问题,请稍后重试", + "network": "网络连接失败,请检查网络", + "timeout": "请求超时,请稍后重试", + "server": "服务器开小差了,请稍后重试", + "auth": "登录已过期,请重新登录", + "empty_input": "请填写必填项", + "invalid_phone": "请输入有效的手机号" + }, + "empty": { + "default": "暂无数据", + "list": "列表为空" + }, + "auth": { + "login_title": "欢迎登录", + "phone_hint": "请输入手机号", + "code_hint": "请输入验证码", + "password_hint": "请输入密码", + "send_code": "发送验证码", + "resend_code": "重新发送", + "sms_tab": "短信验证码", + "pwd_tab": "密码登录" + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..e05a920 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:sunny_mochi/app.dart'; +import 'package:sunny_mochi/core/config/env.dart'; +import 'package:sunny_mochi/core/crash/crash_reporter.dart'; +import 'package:sunny_mochi/core/observability/sentry_setup.dart'; +import 'package:sunny_mochi/core/observability/talker_setup.dart'; +import 'package:sunny_mochi/core/storage/app_database.dart'; +import 'package:sunny_mochi/core/sync/sync_service.dart'; +import 'package:sunny_mochi/features/error_report/data/error_logger.dart'; +import 'package:sunny_mochi/i18n/strings.g.dart'; +import 'package:talker_riverpod_logger/talker_riverpod_logger.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + unawaited(LocaleSettings.useDeviceLocale()); + + // 1. 崩溃日志预初始化(必须在 Sentry 之前) + await CrashReporter.preInit(); + final pendingCrash = CrashReporter.consumePending(); + + // 2. Sentry 初始化(空 DSN 时直接 runApp) + await SentrySetup.init( + appRunner: () async { + // 3. 挂载崩溃钩子 + CrashReporter.installHooks(); + appTalker.info('App boot · env=${Env.name} · mock=${Env.useMock}'); + + // 4. 打开加密 SQLite 数据库 + final database = await AppDatabase.open(); + + runApp( + TranslationProvider( + child: ProviderScope( + retry: (_, _) => null, + overrides: [ + appDatabaseProvider.overrideWithValue(database), + pendingCrashProvider.overrideWithValue(pendingCrash), + ], + observers: [ + if (Env.enableDevPanel) + TalkerRiverpodObserver(talker: appTalker), + ], + child: const _SyncBootstrap(child: AppRoot()), + ), + ), + ); + }, + ); +} + +/// 在 ProviderScope 内启动 SyncService 和 ErrorLogger 清理。 +class _SyncBootstrap extends ConsumerStatefulWidget { + const _SyncBootstrap({required this.child}); + final Widget child; + + @override + ConsumerState<_SyncBootstrap> createState() => _SyncBootstrapState(); +} + +class _SyncBootstrapState extends ConsumerState<_SyncBootstrap> { + @override + void initState() { + super.initState(); + unawaited(ref.read(syncServiceProvider).start()); + unawaited(ref.read(errorLoggerProvider).pruneOld()); + } + + @override + void dispose() { + unawaited(ref.read(syncServiceProvider).dispose()); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} + +class AppRoot extends StatelessWidget { + const AppRoot({super.key}); + + @override + Widget build(BuildContext context) { + // 设计基准 375pt(与 iOS JPScaleValue 一致;如设计方调整需同步) + return const ScreenUtilInit( + designSize: Size(375, 812), + minTextAdapt: true, + child: App(), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..5103d55 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1758 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + url: "https://pub.dev" + source: hosted + version: "92.0.0" + alchemist: + dependency: "direct dev" + description: + name: alchemist + sha256: c7d8c7a1116cbfa22286e8ff45d37efe2c31fc63a82ed06c7ccebc997159c21f + url: "https://pub.dev" + source: hosted + version: "0.14.0" + analysis_server_plugin: + dependency: transitive + description: + name: analysis_server_plugin + sha256: "44adba4d74a2541173bad4c11531d2a4d22810c29c5ddb458a38e9f4d0e5eac7" + url: "https://pub.dev" + source: hosted + version: "0.3.4" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + analyzer_buffer: + dependency: transitive + description: + name: analyzer_buffer + sha256: ff4bd291778c7417fe53fe24ee0d0a1f1ffe281a2d4ea887e7094f16e36eace7 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "6645a029da947ffd823d98118f385d4bd26b54eb069c006b22e0b94e451814b5" + url: "https://pub.dev" + source: hosted + version: "0.13.11" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + asn1lib: + dependency: "direct main" + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + url: "https://pub.dev" + source: hosted + version: "2.15.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + url: "https://pub.dev" + source: hosted + version: "12.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + drift: + dependency: "direct main" + description: + name: drift + sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" + url: "https://pub.dev" + source: hosted + version: "2.31.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" + url: "https://pub.dev" + source: hosted + version: "2.31.0" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + flutter_screenutil: + dependency: "direct main" + description: + name: flutter_screenutil + sha256: "8239210dd68bee6b0577aa4a090890342d04a136ce1c81f98ee513fc0ce891de" + url: "https://pub.dev" + source: hosted + version: "5.9.3" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62" + url: "https://pub.dev" + source: hosted + version: "10.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "67cd1ff671add31dc13e45194398187a04bb63804b37fa47866afae296d73fcb" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 + url: "https://pub.dev" + source: hosted + version: "3.2.5" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" + url: "https://pub.dev" + source: hosted + version: "17.2.3" + go_router_builder: + dependency: "direct dev" + description: + name: go_router_builder + sha256: "52c0403e3fc80db4058d44088f28227f4ecf046f9b8ccbea0e325d3c7b4bb7f5" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + group_button: + dependency: transitive + description: + name: group_button + sha256: "0610fcf28ed122bfb4b410fce161a390f7f2531d55d1d65c5375982001415940" + url: "https://pub.dev" + source: hosted + version: "5.3.4" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_certificate_pinning: + dependency: "direct main" + description: + name: http_certificate_pinning + sha256: "1501c69142a3906a5ad4876d7e71696fa6a6a187fc6f4a746d7a5ba32f9f8fcf" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1 + url: "https://pub.dev" + source: hosted + version: "0.14.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" + url: "https://pub.dev" + source: hosted + version: "6.13.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: transitive + description: + name: mockito + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 + url: "https://pub.dev" + source: hosted + version: "5.6.4" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + url: "https://pub.dev" + source: hosted + version: "2.2.23" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pretty_dio_logger: + dependency: "direct main" + description: + name: pretty_dio_logger + sha256: "36f2101299786d567869493e2f5731de61ce130faa14679473b26905a92b6407" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: e55bc08c084a424e1bbdc303fe8ea75daafe4269b68fd0e0f6f1678413715b66 + url: "https://pub.dev" + source: hosted + version: "1.0.0-dev.9" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: "16471a1260b94e939394d78f1c63a9350936ac4a68c9fbdab40be47268c0b04f" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "6f9220534d7a353b53c875ea191a84d28cb4e52ac420a66a1bd7318329d977b0" + url: "https://pub.dev" + source: hosted + version: "4.0.3" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "64e8debf5b719a37d48b9785dd595d34133fdcd84b8fd07157a621c54ab2156f" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + rxdart: + dependency: "direct main" + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + sentry: + dependency: transitive + description: + name: sentry + sha256: f04095a25ff02b202a914174c73ec309570aa93d61098cb4a0a9e715b4aaa465 + url: "https://pub.dev" + source: hosted + version: "9.20.0" + sentry_flutter: + dependency: "direct main" + description: + name: sentry_flutter + sha256: "4f0a914d8c37e5015d7b86dd42b92dd265948fd31875b43e62ddfc2e7bea0e41" + url: "https://pub.dev" + source: hosted + version: "9.20.0" + serial_csv: + dependency: transitive + description: + name: serial_csv + sha256: "2d62bb70cb3ce7251383fc86ea9aae1298ab1e57af6ef4e93b6a9751c5c268dd" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + share_plus: + dependency: transitive + description: + name: share_plus + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + url: "https://pub.dev" + source: hosted + version: "12.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + slang: + dependency: "direct main" + description: + name: slang + sha256: ea6702ed6b1c82065fb2de906fe34ac9298117342e3c2ea2567132efdc81bd17 + url: "https://pub.dev" + source: hosted + version: "4.14.0" + slang_flutter: + dependency: "direct main" + description: + name: slang_flutter + sha256: dcc4e77527c91b12348fc8bdd43d3eb92d8cea37c12a23a1f9719cdc12c804c6 + url: "https://pub.dev" + source: hosted + version: "4.14.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + url: "https://pub.dev" + source: hosted + version: "1.3.12" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: f8a08a13fb8f0f8c590df89d745000bed44a673ed94bac846739e1a016875c21 + url: "https://pub.dev" + source: hosted + version: "2.5.7" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqlcipher_flutter_libs: + dependency: "direct main" + description: + name: sqlcipher_flutter_libs + sha256: dd1fcc74d5baf3c36ad53e2652b2d06c9f8747494a3ccde0076e88b159dfe622 + url: "https://pub.dev" + source: hosted + version: "0.6.8" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" + url: "https://pub.dev" + source: hosted + version: "0.43.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" + talker: + dependency: transitive + description: + name: talker + sha256: f1a14d623f1d1bec42bb3bb77674eb766ffe8d26e5f79af652d85cb097c3e757 + url: "https://pub.dev" + source: hosted + version: "5.1.17" + talker_dio_logger: + dependency: "direct main" + description: + name: talker_dio_logger + sha256: "6dba5c29afb566c6efe1a2c1b676488ea7c727b486bda0654d965a1cfad6ea9b" + url: "https://pub.dev" + source: hosted + version: "5.1.17" + talker_flutter: + dependency: "direct main" + description: + name: talker_flutter + sha256: "54cbbf852101721664faf4a05639fd2fdefdc37178327990abea00390690d4bc" + url: "https://pub.dev" + source: hosted + version: "5.1.16" + talker_logger: + dependency: transitive + description: + name: talker_logger + sha256: "459205c3e571f97ecc6be6e1b1b7e6b97b853e78ea458894650be407596e3216" + url: "https://pub.dev" + source: hosted + version: "5.1.17" + talker_riverpod_logger: + dependency: "direct main" + description: + name: talker_riverpod_logger + sha256: "0d2e3c436122a602e7f3cfa572c418b96b63dd9b9b6b51871e828501693550c6" + url: "https://pub.dev" + source: hosted + version: "5.1.17" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "4d35a36400983c3457c289d4d553b5308f506ea84f7e51c7a564651b5525209a" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "98e7e94de127b46a86ef46197fff84ff99f3d3b80a708390d717ad731efef598" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: d1cb1d66a5aae2c702d68caca6c8347306d35e728fd94555fa21fa0448a972e0 + url: "https://pub.dev" + source: hosted + version: "10.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..33aa270 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,126 @@ +name: sunny_mochi +description: Flutter 企业级应用脚手架 — 包含 Flavor 三包体系、加密数据库、7 拦截器网络栈、崩溃日志、主题系统等基础底座。 +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.8.0 + flutter: '>=3.41.0' + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + # === 状态管理 + DI === + flutter_riverpod: ^3.3.0 + riverpod_annotation: ^4.0.0 + + # === 路由 === + go_router: ^17.0.0 + + # === JSON / 模型 === + freezed_annotation: ^3.0.0 + json_annotation: ^4.11.0 + + # === 网络 === + dio: ^5.9.0 + http_certificate_pinning: ^3.0.0 + pretty_dio_logger: ^1.3.1 + + # === 持久化(Drift + SQLCipher AES-256)=== + # drift 锁 ^2.0.0 (实际 2.31.0):drift_dev 2.32+ 需 analyzer ^10.0.0,与 riverpod_generator 不兼容 + drift: ^2.0.0 + sqlcipher_flutter_libs: ^0.6.0 + path: ^1.9.0 + flutter_secure_storage: ^10.0.0 + shared_preferences: ^2.0.0 + + # === UI === + flutter_screenutil: ^5.9.0 + cached_network_image: ^3.4.0 + flutter_svg: ^2.0.0 + shimmer: ^3.0.0 + + # === i18n === + intl: ^0.20.0 + slang: ^4.0.0 + slang_flutter: ^4.0.0 + + # === 加密(RSA PKCS#1 v1.5 公钥加密)=== + pointycastle: ^4.0.0 + asn1lib: ^1.5.0 + + # === 工具 === + collection: ^1.18.0 + rxdart: ^0.28.0 + path_provider: ^2.1.0 + package_info_plus: ^9.0.0 + device_info_plus: ^12.0.0 + connectivity_plus: ^7.0.0 + permission_handler: ^12.0.0 + url_launcher: ^6.3.0 + uuid: ^4.0.0 + equatable: ^2.0.5 + image_picker: ^1.0.0 + + # === 日志 + 崩溃监控 === + talker_flutter: ^5.0.0 + talker_dio_logger: ^5.0.0 + talker_riverpod_logger: ^5.0.0 + sentry_flutter: ^9.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + + # === 代码生成 === + build_runner: ^2.4.0 + freezed: ^3.2.5 + # json_serializable 锁 6.13.0:6.13.1+ 需 analyzer ^10.0.0,与 riverpod_lint 互斥 + json_serializable: ^6.13.0 + riverpod_generator: ^4.0.0 + # drift_dev 锁 2.31:2.32+ 需 analyzer ^10.0.0 + drift_dev: ^2.0.0 + go_router_builder: ^4.3.0 + + # === 测试 === + mocktail: ^1.0.0 + alchemist: ^0.14.0 + + # === Lint === + very_good_analysis: ^10.0.0 + riverpod_lint: ^3.1.0 + +# 版本锁定(解决 analyzer 版本冲突,待 Tier 2.0 原子升级后移除) +dependency_overrides: + drift: 2.31.0 + json_serializable: 6.13.0 + +flutter: + uses-material-design: true + + assets: + - assets/images/ + - assets/icons/ + - assets/icons/tabs/ + - assets/themes/ + - assets/fixtures/ + - assets/fixtures/auth/ + - assets/fixtures/common/ + - assets/fixtures/mine/ + + fonts: + - family: HarmonyOS Sans SC + fonts: + - asset: assets/fonts/HarmonyOS_Sans_SC_Regular.ttf + weight: 400 + - asset: assets/fonts/HarmonyOS_Sans_SC_Medium.ttf + weight: 500 + - asset: assets/fonts/HarmonyOS_Sans_SC_Bold.ttf + weight: 700 + - asset: assets/fonts/HarmonyOS_Sans_SC_Black.ttf + weight: 900 diff --git a/slang.yaml b/slang.yaml new file mode 100644 index 0000000..fcf57b2 --- /dev/null +++ b/slang.yaml @@ -0,0 +1,10 @@ +input_directory: lib/i18n +input_file_pattern: .i18n.json +output_directory: lib/i18n +output_file_name: strings.g.dart + +locale_handling: true +flutter_integration: true +fallback_strategy: base_locale + +base_locale: zh-CN diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..21fd0d2 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,9 @@ +import 'package:flutter_test/flutter_test.dart'; + +// Widget smoke tests — TODO: add proper integration tests +void main() { + testWidgets('placeholder', (WidgetTester tester) async { + // TODO: add widget tests + expect(true, isTrue); + }); +}