diff --git a/.dbg/avatar-orientation.env b/.dbg/avatar-orientation.env new file mode 100644 index 0000000..b4ea31f --- /dev/null +++ b/.dbg/avatar-orientation.env @@ -0,0 +1,2 @@ +DEBUG_SERVER_URL=http://192.168.77.158:7777/event +DEBUG_SESSION_ID=avatar-orientation diff --git a/.dbg/trae-debug-log-avatar-orientation.ndjson b/.dbg/trae-debug-log-avatar-orientation.ndjson new file mode 100644 index 0000000..d3f4f2c --- /dev/null +++ b/.dbg/trae-debug-log-avatar-orientation.ndjson @@ -0,0 +1,5 @@ +{"sessionId": "avatar-orientation", "runId": "post-fix", "hypothesisId": "A", "location": "my_page.dart:_onAvatarTap:pick", "msg": "[DEBUG] Avatar image picked", "data": {"name": "CAP6240497511992036589.jpg", "hasBytes": true, "hasEntity": false, "path": "/data/user/0/com.yuanxuan.aixuediebian/cache/CAP6240497511992036589.jpg"}, "ts": 1786958407658} +{"sessionId": "avatar-orientation", "runId": "post-fix", "hypothesisId": "B", "location": "my_page.dart:_onAvatarTap:bytes", "msg": "[DEBUG] Avatar source bytes resolved", "data": {"name": "CAP6240497511992036589.jpg", "bytesLength": 3877494, "source": "picked-bytes"}, "ts": 1786958407663} +{"sessionId": "avatar-orientation", "runId": "post-fix", "hypothesisId": "D", "location": "my_page.dart:_onAvatarTap:processor", "msg": "[DEBUG] Image processed", "data": {"name": "CAP6240497511992036589.jpg", "inputBytes": 3877494, "resultNull": false, "processedBytes": 57161, "contentType": "image/jpeg", "decodedWidth": 2160, "decodedHeight": 3840, "orientedWidth": 2160, "orientedHeight": 3840, "outputWidth": 288, "outputHeight": 512, "hasExifOrientation": false, "exifOrientationBefore": null, "exifOrientationAfter": null, "resized": true}, "ts": 1786958409161} +{"sessionId": "avatar-orientation", "runId": "post-fix", "hypothesisId": "C", "location": "my_page.dart:_onAvatarTap:upload", "msg": "[DEBUG] Avatar upload finished", "data": {"url": "https://hx.cnzhyx.cn/Avatar/2026-08-17/CAP6240497511992036589.jpg", "processedBytes": 57161, "contentType": "image/jpeg", "fileName": "CAP6240497511992036589.jpg", "pickedAvatarPresentBeforeClear": true}, "ts": 1786958409680} +{"sessionId": "avatar-orientation", "runId": "post-fix", "hypothesisId": "C", "location": "my_page.dart:_onAvatarTap:preview-clear", "msg": "[DEBUG] Local picked avatar cleared after upload", "data": {"pickedAvatarPresentAfterClear": false, "serverAvatarUrl": "https://hx.cnzhyx.cn/Avatar/2026-08-17/CAP6240497511992036589.jpg"}, "ts": 1786958409698} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..2557b48 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# 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: "058e0af2c2b57e369d905a03ac9748b0ebf543c6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: web + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + + # 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/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ea9269a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "cSpell.words": [ + "chewie", + "Refreshable", + "riverpod", + "screenutil" + ] +} \ No newline at end of file diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md new file mode 100644 index 0000000..e69de29 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options 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..334f7df --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,92 @@ +import java.io.FileInputStream +import java.util.Properties + +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +// ============================================================ +// 发布签名配置(正式包) +// +// 1. 生成 keystore(一次性,命令在 android/ 目录下执行): +// keytool -genkey -v -keystore app/upload-keystore.jks ^ +// -keyalg RSA -keysize 2048 -validity 10000 -alias key +// (会让你设两个密码 + 填公司信息;这两个密码务必保管好, +// 丢了=正式包永远无法升级,只能换包名重新上架) +// 2. 把密码填进 android/key.properties(该文件已被 git 忽略,不会泄漏) +// 3. 填完后执行 flutter build apk --release / flutter build appbundle +// +// 兜底:key.properties 不存在或 storeFile 为空时, +// 自动降级用 debug 签名(仅便于本机安装,不能上架/升级)。 +// ============================================================ +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +val storeFilePath = if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + keystoreProperties.getProperty("storeFile", "") +} else { + "" +} +val hasReleaseKey = keystorePropertiesFile.exists() && storeFilePath.isNotBlank() + +android { + namespace = "com.yuanxuan.aixuediebian" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + + defaultConfig { + // 应用唯一标识(applicationId)。改了=新包,与旧包不共存。 + // namespace 同此包名(MainActivity.kt 的 package 也保持同步)。 + applicationId = "com.yuanxuan.aixuediebian" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + create("release") { + if (hasReleaseKey) { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = file(storeFilePath) + storePassword = keystoreProperties.getProperty("storePassword") + } + } + } + + buildTypes { + release { + // 有正式签名 → 用 release 签名;否则降级 debug 签名(仅便于本地安装调试) + signingConfig = if (hasReleaseKey) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") +} 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..59d78d6 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/yuanxuan/aixuediebian/MainActivity.kt b/android/app/src/main/kotlin/com/yuanxuan/aixuediebian/MainActivity.kt new file mode 100644 index 0000000..136af15 --- /dev/null +++ b/android/app/src/main/kotlin/com/yuanxuan/aixuediebian/MainActivity.kt @@ -0,0 +1,5 @@ +package com.yuanxuan.aixuediebian + +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 b/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} 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..5be8ee9 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,16 @@ +# 修复:JVM 崩溃(hs_err_pid*.log "Out of Memory Error")。 +# 原 8G 堆 + 4G 元空间共 12G+,加上 Flutter/模拟器/浏览器同时跑, +# 机器 27G 内存不够 JVM mmap 8G 虚拟空间 → Gradle daemon 被杀。 +# 学习型项目 2G 堆 + 512M 元空间足够,还能给 dart 编译/模拟器留出内存。 +org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false +# 修复:项目在 D 盘、pub 缓存(shared_preferences 等插件源码)在 C 盘, +# Kotlin 增量编译跨盘算相对路径会抛 "different roots" 导致构建失败。 +# 关闭增量编译即可(本工程规模下几乎无性能影响)。 +kotlin.incremental=false +# 限制 Kotlin daemon 内存(默认会继承 Gradle 大堆,双份 JVM 吃内存) +kotlin.daemon.jvmargs=-Xmx1G -XX:MaxMetaspaceSize=512m diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /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-9.1.0-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..643d520 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return 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 "9.0.1" apply false + id "org.jetbrains.kotlin.android" version "2.3.20" apply false +} + +include ":app" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..32f95c4 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,31 @@ +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 { + // 阿里云镜像放在最前面 + maven { url = uri("https://maven.aliyun.com/repository/google") } + maven { url = uri("https://maven.aliyun.com/repository/public") } + maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") } + + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") \ No newline at end of file diff --git a/assets/audio/sample-30s.mp3 b/assets/audio/sample-30s.mp3 new file mode 100644 index 0000000..24ada4a Binary files /dev/null and b/assets/audio/sample-30s.mp3 differ diff --git a/assets/img/tabbar/class.png b/assets/img/tabbar/class.png new file mode 100644 index 0000000..0b47c65 Binary files /dev/null and b/assets/img/tabbar/class.png differ diff --git a/assets/img/tabbar/default/classChoose.png b/assets/img/tabbar/default/classChoose.png new file mode 100644 index 0000000..967b791 Binary files /dev/null and b/assets/img/tabbar/default/classChoose.png differ diff --git a/assets/img/tabbar/default/indexChoose.png b/assets/img/tabbar/default/indexChoose.png new file mode 100644 index 0000000..b9d1a6c Binary files /dev/null and b/assets/img/tabbar/default/indexChoose.png differ diff --git a/assets/img/tabbar/default/myChoose.png b/assets/img/tabbar/default/myChoose.png new file mode 100644 index 0000000..09dad10 Binary files /dev/null and b/assets/img/tabbar/default/myChoose.png differ diff --git a/assets/img/tabbar/default/reciteChoose.png b/assets/img/tabbar/default/reciteChoose.png new file mode 100644 index 0000000..c86f5a9 Binary files /dev/null and b/assets/img/tabbar/default/reciteChoose.png differ diff --git a/assets/img/tabbar/index.png b/assets/img/tabbar/index.png new file mode 100644 index 0000000..9e5625a Binary files /dev/null and b/assets/img/tabbar/index.png differ diff --git a/assets/img/tabbar/my.png b/assets/img/tabbar/my.png new file mode 100644 index 0000000..d1892d7 Binary files /dev/null and b/assets/img/tabbar/my.png differ diff --git a/assets/img/tabbar/recite.png b/assets/img/tabbar/recite.png new file mode 100644 index 0000000..7aea196 Binary files /dev/null and b/assets/img/tabbar/recite.png differ diff --git a/assets/luola_logo.png b/assets/luola_logo.png new file mode 100644 index 0000000..71f41a3 Binary files /dev/null and b/assets/luola_logo.png differ diff --git a/debug-avatar-orientation.md b/debug-avatar-orientation.md new file mode 100644 index 0000000..5bcb1f9 --- /dev/null +++ b/debug-avatar-orientation.md @@ -0,0 +1,40 @@ +# Debug Session: avatar-orientation +- **Status**: [OPEN] +- **Issue**: 手机拍照后上传头像,期望经过 EXIF 方向修正后始终正向显示;实际上传成功后仍保持拍照时的方向,未自动转正。 +- **Debug Server**: http://127.0.0.1:7777/event (Android 通过 `adb reverse tcp:7777 tcp:7777` 回传) +- **Log File**: .dbg/trae-debug-log-avatar-orientation.ndjson + +## Reproduction Steps +1. Android 真机打开 App。 +2. 进入“我的”页,点击头像。 +3. 选择“拍照”,使用横屏/竖屏拍摄一张照片并确认。 +4. 等待上传成功,观察最终头像是否已经转正。 + +## Hypotheses & Verification +| ID | Hypothesis | Likelihood | Effort | Evidence | +|----|------------|------------|--------|----------| +| A | 拍照返回的字节已被系统或插件提前烘焙,EXIF orientation 不存在或恒为 1,所以 `bakeOrientation` 无事可做 | High | Low | Confirmed | +| B | `originBytes` 读取到的不是原始照片数据,丢失了 EXIF;而 `originFile/file` 路径上的文件与之不同 | High | Medium | Confirmed | +| C | 图片确实已旋正上传,但显示层仍在展示未处理的本地预览或缓存图 | Medium | Low | Rejected | +| D | `image` 包已读到 EXIF,但在当前手机样本上 `bakeOrientation` 前后宽高/方向无变化,问题出在更早的相机输出阶段 | Medium | Medium | Pending | +| E | OSS / CDN 返回的是旧缓存图,导致看到的仍是原方向 | Low | Medium | Pending | + +## Log Evidence +- `my_page.dart:_onAvatarTap:pick`:两次拍照都返回 `hasEntity=true`、`source=entity-originBytes` +- `my_page.dart:_onAvatarTap:upload`:两次都成功上传,分别得到新的 JPG URL,说明上传链路正常 +- `my_page.dart:_onAvatarTap:preview-clear`:上传成功后 `_pickedAvatar` 已清空,页面不再优先显示本地临时预览 +- `my_page.dart:_onAvatarTap:processor`: + - `hasExifOrientation=false` + - `exifOrientationBefore=null` + - `exifOrientationAfter=null` + - `decodedWidth=2160, decodedHeight=3840` + - `orientedWidth=2160, orientedHeight=3840` + - `resized=true` + 说明进入处理链的字节已经**没有 EXIF orientation**,`bakeOrientation` 前后没有任何方向变化空间 + +## Verification Conclusion +- 已确认上传成功且显示层已切回服务器图,**C 被排除** +- 根因已锁定: + - A:相机输出最终交给业务层的字节本身已无 EXIF orientation + - B:`wechat_camera_picker` 默认保存为 `AssetEntity` 后,再走 `entity-originBytes` 会丢失最初拍照文件里的方向信息 + - D:`bakeOrientation` 前后没有任何变化,是因为输入里已经没有可烘焙的 orientation diff --git a/debug-flutter-compile-crash.md b/debug-flutter-compile-crash.md new file mode 100644 index 0000000..52be7c1 --- /dev/null +++ b/debug-flutter-compile-crash.md @@ -0,0 +1,27 @@ +[OPEN] + +# flutter-compile-crash + +## 症状 +- `flutter run` 编译阶段崩溃:`Crash when compiling: Null check operator used on a null value` +- 堆栈来自 Flutter kernel transformation:`track_widget_constructor_locations.dart` +- `flutter run --no-track-widget-creation` 也无法启动 + +## 预期 +- App 可正常编译启动 + +## 假设 (待证伪) +- H1: Flutter SDK/Dart 版本与新增依赖(connectivity_plus / flutter_local_notifications / timezone / flutter_timezone)存在最低版本不兼容,导致前端工具链在 kernel transform 阶段崩溃 +- H2: Pub 缓存或依赖解析异常(镜像/缓存损坏/半下载)导致编译输入不完整,从而触发 kernel transform NPE +- H3: 代码中存在导致 kernel transform 崩溃的语法/构造(与 Widget 创建追踪相关),例如工具链在处理某处 AST 时拿不到 source location +- H4: Flutter 工具链 bug(特定版本已知问题),需要通过升级/降级 Flutter 或禁用某些 transform 来规避 + +## 需要收集的证据 +- Flutter/Dart 版本:`flutter --version` +- 工具链信息:`flutter doctor -v` +- 依赖解析:`flutter pub get -v` 的关键错误(如版本约束/缓存/镜像) +- 编译日志:`flutter run -v` 的完整崩溃段落(含“是哪一个文件/库正在编译”) + +## 进展记录 +- 2026-09-02:创建调试记录,等待用户提供版本与 verbose 日志 + diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..745e0a9 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,6 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - riverpod: true + - provider: true + - shared_preferences: true \ No newline at end of file diff --git a/docs/build_runner_guide.md b/docs/build_runner_guide.md new file mode 100644 index 0000000..fd763b4 --- /dev/null +++ b/docs/build_runner_guide.md @@ -0,0 +1,71 @@ +# build_runner 代码生成指南 + +## 一、是什么 + +`build_runner` 是 Dart 官方的**代码生成调度器**。它本身不生成任何代码,而是统一调度各个**生成器插件**,根据模型上的注解自动生成 `.g.dart` 文件。 + +类比:`build_runner` = 工厂流水线调度员,各个生成器插件 = 流水线上的工人。 + +## 二、当前项目的生成器 + +| 生成器插件 | 注解 | 生成内容 | 状态 | +|---|---|---|---| +| `isar_community_generator` | `@collection` | Isar 数据库 Schema(增删改查) | ✅ 已配置 | +| `json_serializable` | `@JsonSerializable()` | `fromJson` / `toJson` 方法 | 🔲 待添加 | + +一次 `build_runner build` 会**同时调度所有已配置的生成器**,全部 `.g.dart` 一次性生成。 + +## 三、涉及的模型文件 + +### Isar 模型(`@collection`) + +| 文件 | 说明 | +|---|---| +| `lib/core/storage/models/question.dart` | 题目 | +| `lib/core/storage/models/wrong_question.dart` | 错题 | +| `lib/core/storage/models/answer_record.dart` | 答题记录 | +| `lib/core/storage/models/play_record.dart` | 播放记录 | +| `lib/core/version/app_version_entity.dart` | 版本信息实体 | + +### json_serializable 模型(`@JsonSerializable`) + +> 待引入后,纯接口 DTO 模型(如 `UserPointRecord`、`OssStsConfig` 等)将使用注解自动生成序列化代码,替代手写 `fromJson`。 + +## 四、使用命令 + +```bash +# 生成一次(推荐日常使用) +dart run build_runner build + +# 清理旧产物后重新生成(遇到生成代码冲突时使用) +dart run build_runner build --delete-conflicting-outputs + +# 监听模式:每次保存文件自动生成(开发阶段推荐) +dart run build_runner watch +``` + +## 五、什么时候需要跑 + +| 操作 | 是否需要跑 | +|---|---| +| Isar 模型加了/删了/改了字段 | ✅ 必须,否则编译报错 | +| `@JsonSerializable` 模型改了字段 | ✅ 必须,否则序列化字段对不上 | +| 只改了页面 UI / 业务逻辑 | ❌ 不需要 | +| 只改了 `pubspec.yaml` 依赖 | ❌ 不需要(跑 `flutter pub get` 即可) | + +## 六、生成产物说明 + +- 生成文件命名:`xxx.g.dart`,和模型文件同目录 +- 生成文件**必须提交到 Git**(`.gitignore` 里不要排除 `*.g.dart`) +- **不要手动编辑** `.g.dart` 文件,改了也会被下次生成覆盖 + +## 七、常见问题 + +### Q: 编译报错 `MissingStubError` 或 `is not a subtype of` +A: 模型字段改了但没跑 build_runner,重新生成即可。 + +### Q: 报 `AlreadyExistsException` 或 conflicting outputs +A: 加 `--delete-conflicting-outputs` 参数清理后重新生成。 + +### Q: 生成很慢怎么办 +A: 开发阶段用 `watch` 模式,增量生成比每次全量 `build` 快很多。 diff --git a/docs/demos/README.md b/docs/demos/README.md new file mode 100644 index 0000000..31b7477 --- /dev/null +++ b/docs/demos/README.md @@ -0,0 +1,114 @@ +# docs/demos —— Demo 学习文档索引 + +> 本目录为 `lib/pages/demo/` 下 **29 个独立功能模块**逐一配套的学习文档, +> 面向 Flutter 初学者:讲清**实现用法和思想**(为什么这么写),并标注踩坑记录。 + +**配套文档**:项目整体架构(含 Riverpod / Isar 新手教程)见 [../exam_architecture.md](../exam_architecture.md), +本目录是"每个功能怎么实现"的细节版,两者配合阅读效果最佳。 + +--- + +## 一、分类索引(29 个) + +### 🧱 基础布局(13) +| Demo 文件 | 一句话 | 文档 | +|---|---|---| +| align_demo.dart | 对齐与约束:一行代码把一个组件摆到任意位置 | [align_demo.md](align_demo.md) | +| wrap_demo.dart | 流式布局:小标签自动换行,放不下就换一排 | [wrap_demo.md](wrap_demo.md) | +| stack_demo.dart | 层叠布局:角标、遮罩、悬浮按钮全靠它 | [stack_demo.md](stack_demo.md) | +| grid_demo.dart | 网格布局:GridView 三列宫格(九宫格) | [grid_demo.md](grid_demo.md) | +| list_demo.dart | 长列表:ListView.builder 懒加载 + 下拉刷新 | [list_demo.md](list_demo.md) | +| sliver_demo.dart | 可折叠顶栏:SliverAppBar 收起展开 + 吸顶 | [sliver_demo.md](sliver_demo.md) | +| responsive_demo.dart | 响应式:一个页面同时适配手机/平板/桌面 | [responsive_demo.md](responsive_demo.md) | +| text_demo.dart | 文本组件:样式、行高、溢出省略号 | [text_demo.md](text_demo.md) | +| input_demo.dart | 输入框:TextField 全家桶 + 键盘类型 | [input_demo.md](input_demo.md) | +| form_demo.dart | 表单校验:GlobalKey + validator 规则 | [form_demo.md](form_demo.md) | +| theme_demo.dart | 主题:多品牌换肤、全局样式统一管理 | [theme_demo.md](theme_demo.md) | +| seed_palette_demo.dart | 种子色:ColorScheme.fromSeed 一键生成配色 | [seed_palette_demo.md](seed_palette_demo.md) | +| animation_demo.dart | 动画:隐式/显式/路由转场/Hero/CustomPaint | [animation_demo.md](animation_demo.md) | + +### 🌐 网络(3) +| Demo 文件 | 一句话 | 文档 | +|---|---|---| +| dio_demo.dart | 请求封装:DioClient + ApiException 统一错误 | [dio_demo.md](dio_demo.md) | +| dio_pro_demo.dart | 进阶:拦截器/重试/CancelToken/Web 兼容 | [dio_pro_demo.md](dio_pro_demo.md) | +| oss_upload_demo.dart | 阿里云 OSS 直传:STS 临时凭证 + V4 签名 | [oss_upload_demo.md](oss_upload_demo.md) | + +### 💾 存储(2) +| Demo 文件 | 一句话 | 文档 | +|---|---|---| +| isar_watch_demo.dart | 本地库 + 响应式:Isar 增删改查 + watch 自动刷新 | [isar_watch_demo.md](isar_watch_demo.md) | +| wrong_flow_demo.dart | 完整业务链路:接口 + 本地 + watch 三层协作 | [wrong_flow_demo.md](wrong_flow_demo.md) | + +### 🧭 路由(4) +| Demo 文件 | 一句话 | 文档 | +|---|---|---| +| router_demo.dart | 应用入口:路由表注册 + 首页组装 | [router_demo.md](router_demo.md) | +| router_home_page.dart | 三种跳转姿势:push 传参 / await 返回值 / pushNamed | [router_home_page.md](router_home_page.md) | +| detail_page.dart | 参数接收:构造传参 vs 命名路由 arguments | [detail_page.md](detail_page.md) | +| picker_page.dart | 选择器页:pop 带值返回(选性别) | [picker_page.md](picker_page.md) | + +### 🧩 组件封装(6) +| Demo 文件 | 一句话 | 文档 | +|---|---|---| +| note_picker_demo.dart | 多图选择页:加号格 + 缩略图 + 删除角标 | [note_picker_demo.md](note_picker_demo.md) | +| image_demo.dart | 图片四来源 + BoxFit + 错误兜底 | [image_demo.md](image_demo.md) | +| audio_player_demo.dart | 音频控制条组件(HTTP Range 验收) | [audio_player_demo.md](audio_player_demo.md) | +| rich_text_demo.dart | HTML 富文本渲染 + 字号/深色注入 | [rich_text_demo.md](rich_text_demo.md) | +| smooth_sheet_demo.dart | 常驻多停靠底部面板(非模态) | [smooth_sheet_demo.md](smooth_sheet_demo.md) | +| share_demo.dart | 分享五场景:文本/图片/海报截图/存相册 | [share_demo.md](share_demo.md) | + +### 🎓 考试(1) +| Demo 文件 | 一句话 | 文档 | +|---|---|---| +| exam_demo.dart | 试卷练习入口:大数据滑动验证(关联主架构) | [exam_demo.md](exam_demo.md) | + +--- + +## 二、推荐学习路径(按顺序) + +``` +阶段 1 布局基础(约 1 周) + align → wrap → stack → grid → list → sliver → responsive + +阶段 2 文本与输入 + text → input → form + +阶段 3 主题与动画 + seed_palette → theme → animation + +阶段 4 路由导航 + router → router_home_page → detail_page → picker_page + +阶段 5 数据层 + isar_watch → wrong_flow + +阶段 6 网络层 + dio → dio_pro → oss_upload + +阶段 7 组件封装(进阶) + image → audio_player → rich_text → smooth_sheet → note_picker → share + +阶段 8 综合实战 + exam_demo → ../exam_architecture.md(完整架构) +``` + +> 建议:前 4 个阶段每天 1~2 个 demo,把每份文档"关键代码剖析"手敲一遍; +> 阶段 5 之后开始读 [../exam_architecture.md](../exam_architecture.md)(Riverpod/Isar 前置知识)。 + +--- + +## 三、文档公约 + +每份文档统一六章结构,方便快速定位: + +| 章节 | 内容 | +|---|---| +| 一、这个 Demo 解决什么问题 | 业务场景 + 对照 Vue 类比 | +| 二、怎么运行 / 演示内容 | runApp 命令 + 演示点 | +| 三、核心知识点(新手向) | 速查表 + 代码块 + 为什么这么写 | +| 四、关键代码剖析 | 核心代码逐行解释 | +| 五、踩坑记录 | 编号列表,每条 = 一个真实教训 | +| 六、扩展思考 | 关联其他文档 + 真实项目落地建议 | + +文档之间用相对链接互引(如 `[list_demo.md](list_demo.md)`),GitHub / IDE 均可直接跳转。 diff --git a/docs/demos/align_demo.md b/docs/demos/align_demo.md new file mode 100644 index 0000000..40f8987 --- /dev/null +++ b/docs/demos/align_demo.md @@ -0,0 +1,130 @@ +# 【Demo】Align 对齐布局 + Riverpod 全局用户信息 + +> 源文件:`lib/pages/demo/align_demo.dart` | 分类:基础布局 +> 一句话:把子组件放到父容器任意位置,顺便演示本项目最小化的 Riverpod 状态读取。 + +--- + +## 一、这个 Demo 解决什么问题 + +新手写 UI 时常遇到一个需求:**"把某个卡片放到盒子的左下角 / 右上角 / 居中"**。 +`Align` 就是 Flutter 里的"定位器"——给定一个区域,把子组件按 `alignment` 摆到指定位置。 + +本 Demo 额外演示了 **Riverpod 全局用户信息**的最小用法: +- 页面改成 `ConsumerWidget`,用 `ref.watch(currentUserProvider)` 读全局用户; +- 登录后任何页面都能拿到用户(用户名 / 班级 / 积分),且数据变化时卡片**自动刷新**。 + +> 对照 Vue3:`Align` 类似 `position: absolute` + 容器相对定位; +> `ref.watch` 类似 Pinia 的响应式 state —— 一处修改,处处生效。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const ProviderScope( + child: MaterialApp(home: AlignDemoPage()), +)); +``` + +页面主体是一个灰色 300×300 盒子,左下角悬浮一张蓝色"用户信息卡"。 +登录后卡片内容会跟着变(未登录显示"未登录")。 + +--- + +## 三、核心知识点(新手向) + +### 1. Flutter 布局的两条铁律 +- **父组件给子组件传"约束"(Constraints),子组件在约束范围内决定自己的"大小"**; +- 所有布局问题(溢出、撑不开)都可以回到这条规则上找原因。 + +### 2. Align 的工作方式 +1. Align 会把自身区域**扩大到父组件允许的最大范围**(默认撑满); +2. 然后按 `alignment` 把子组件放到指定位置; +3. 子组件如果不写宽高,Align 取它的**固有尺寸**;写了就按写的来。 + +```dart +Align( + alignment: Alignment.bottomLeft, // 子组件放到父盒子左下角 + child: Container(...), // 用户信息卡片 +) +``` + +### 3. Alignment 坐标系 +`Alignment(x, y)` 的 x、y 范围都是 **-1.0 ~ 1.0**: + +| 值 | 位置 | +|---|---| +| (-1, -1) | 左上 | +| (0, 0) | 居中 | +| (1, 1) | 右下 | +| `Alignment.centerLeft` | 左中 | + +常用常量:`topLeft / topCenter / topRight / centerLeft / center / centerRight / bottomLeft / bottomCenter / bottomRight`。 + +### 4. Riverpod 最小用法(本项目统一模式) +```dart +class AlignDemoPage extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(currentUserProvider).value; // 订阅全局用户 + // user 为 null = 未登录;登录了就是 CurrentUser 对象 + } +} +``` +- **`ref.watch` 必须在 `build` 里调用**(声明式依赖,状态变了自动重建); +- **`ref.read` 只能放回调里**(一次性读取,不建立依赖)。 + +--- + +## 四、关键代码剖析 + +```dart +body: Container( + width: 300.w, + height: 300.h, + color: Colors.grey[300], + child: Align( + alignment: Alignment.bottomLeft, + child: Container( + width: 240.w, // 写了宽高 → 由子组件自己决定 + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(8.w), + ), + child: Column( + mainAxisSize: MainAxisSize.min, // 卡片高度跟随内容 + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('用户:${user?.userName ?? '未登录'}'), + Text('班级:${user?.className ?? '-'}'), + Text('积分:${user?.userPoints ?? 0}'), + ], + ), + ), + ), +), +``` + +要点: +- `mainAxisSize: MainAxisSize.min` —— Column 不占满父级,高度跟随内容(否则卡片会撑满整个 Align 区域); +- `user?.xxx ?? 默认值` —— 空安全写法:用户为空时显示占位。 + +--- + +## 五、踩坑记录 + +1. **用了 `ref` 的组件必须继承 `ConsumerWidget` / `ConsumerStatefulWidget`**,普通 `StatelessWidget` 拿不到 `ref`(编译报错)。 +2. **`ref.watch` 放 build 里,`ref.read` 放回调里**,反了会导致不刷新或性能问题。 +3. **加了 `.w/.h/.sp` 的 widget 不能再用 `const`** —— 尺寸是运行期算的,`const` 会编译报错(`const_eval_extension_method`)。 +4. `Align` 的 `alignment` 只对**没被 `Positioned` 包裹**的子组件生效(`Positioned` 是 Stack 的专属,见 [stack_demo.md](stack_demo.md))。 + +--- + +## 六、扩展思考 + +- **`Center` 就是 `Align(alignment: Alignment.center)` 的别名**,记住 Align 就记住了 Center; +- 同族定位组件:`Padding`(内边距)、`FractionallySizedBox`(按比例尺寸)、`SizedBox`(固定尺寸); +- 想"按百分比偏移"用 `Alignment(x, y)`;想"按像素偏移"把 Align 换成 `Padding` 或包一层 `Transform.translate`; +- Riverpod 进阶:`ref.watch(provider).value` 是 `AsyncValue` 的取值方式,三态(loading/error/data)用法见 [exam_demo.md](exam_demo.md) 与 `docs/exam_architecture.md` 的"零、新手前置"章节。 diff --git a/docs/demos/animation_demo.md b/docs/demos/animation_demo.md new file mode 100644 index 0000000..55cf61f --- /dev/null +++ b/docs/demos/animation_demo.md @@ -0,0 +1,177 @@ +# 【Demo】动画体系(隐式 / 显式 / Hero / CustomPaint) + +> 源文件:`lib/pages/demo/animation_demo.dart` | 分类:基础布局 +> 一句话:对 CSS 学 Flutter 动画 —— 隐式=transition,显式=keyframes,加上 Hero 转场和 Canvas 自绘。 + +--- + +## 一、这个 Demo 解决什么问题 + +新手面对 Flutter 动画容易懵:组件太多、概念太多。本页把动画体系按"对 CSS"的方式梳理成 4 块: + +| 动画类型 | 对标 CSS | 一句话 | +|---|---|---| +| ① 隐式动画 | transition | 属性变了自己补过渡帧,零 controller | +| ② 显式动画 | keyframes | controller 手动驱动,精确控制时序/曲线 | +| ③ Hero 转场 | 无(原生独有) | 页面跳转时元素"飞过去" | +| ④ CustomPaint | canvas | 用 Canvas 画任意图形 | + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: AnimationDemoPage())); +``` + +1. 点方块:宽/圆角/颜色三属性同时变(AnimatedContainer); +2. 答题互动:选对"弹跳放大",选错"水平抖动"(TweenSequence 分段动画); +3. 点封面:共享元素飞到详情页(Hero); +4. 拖滑块:进度环实时重绘(CustomPainter drawArc)。 + +--- + +## 三、核心知识点(新手向) + +### 1. 隐式 vs 显式(判断口诀) +- 只需要"A 变成 B 的过渡" → **隐式**(写一行 `duration` 完事); +- 需要"按我的节奏动起来"(回弹、抖动、连续) → **显式 controller**。 + +```dart +// 隐式:属性变了自动补过渡(duration 必写!漏了就瞬变) +AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + width: _boxExpanded ? 200.w : 120.w, + decoration: BoxDecoration( + color: _boxExpanded ? Color(0xFF3B82F6) : Color(0xFFFF9800), + borderRadius: BorderRadius.circular(_boxExpanded ? 45.r : 8.r), + ), +) +``` + +### 2. 显式动画五件套 +```dart +// ① 页面必须 with SingleTickerProviderStateMixin +class _State extends State with SingleTickerProviderStateMixin { + // ② controller:驱动一切(vsync 必传,duration 定义时长) + late final AnimationController _answerCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + + // ③ TweenSequence:分段动画(先放大再回弹 = 两个 Tween 按权重拼) + late final Animation _correctScale = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 1.25).chain(CurveTween(curve: Curves.easeOut)), + weight: 40, + ), + TweenSequenceItem( + tween: Tween(begin: 1.25, end: 1.0).chain(CurveTween(curve: Curves.bounceOut)), + weight: 60, + ), + ]).animate(_answerCtrl); + + // ④ 触发:每次从 0 重新播放 + _answerCtrl.forward(from: 0); + + // ⑤ 红线:必须 dispose + @override + void dispose() { + _answerCtrl.dispose(); + super.dispose(); + } +} +``` + +### 3. AnimatedBuilder 的 child 缓存 +```dart +AnimatedBuilder( + animation: _correctScale, + child: body, // 不变的子树用 child 传进来缓存,避免 builder 反复重建 + builder: (context, child) => Transform.scale(scale: _correctScale.value, child: child), +) +``` + +### 4. Hero 共享元素 +```dart +// 页面 A +Hero(tag: 'hero-demo-cover', child: Container(width: 120.w, height: 72.h, ...)); +// 页面 B(同一个 tag!) +Hero(tag: 'hero-demo-cover', child: Container(width: 280.w, height: 168.h, ...)); +``` +两个页面里 tag 相同的 Hero,转场时自动共享动画(封面从小图"飞"成大图)。 + +### 5. CustomPainter 自绘进度环 +```dart +class _ProgressRingPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final rect = (Offset.zero & size).deflate(stroke / 2); // 内缩一个线宽 + // 背景环(画满 360°) + canvas.drawArc(rect, -0.5 * 3.14159, 2 * 3.14159, false, bgPaint); + // 前景环:起点 -90° = 12 点方向,顺时针画 progress 比例 + canvas.drawArc(rect, -0.5 * 3.14159, 2 * 3.14159 * progress, false, fgPaint); + } + + @override + bool shouldRepaint(_ProgressRingPainter oldDelegate) { + // 参数变了才重绘(滑块拖动时) + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} +``` +要点:`drawArc` 的角度单位是弧度,`-π/2` 才是 12 点方向(画布 0° 在 3 点方向)。 + +--- + +## 四、关键代码剖析 + +### 抖动动画(4 段 Tween 拼出"抖三下") +```dart +late final Animation _wrongShake = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: -8.0).chain(CurveTween(curve: Curves.easeOut)), weight: 20), + TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 30), + TweenSequenceItem(tween: Tween(begin: 8.0, end: -6.0), weight: 30), + TweenSequenceItem(tween: Tween(begin: -6.0, end: 0.0), weight: 20), +]).animate(_answerCtrl); +``` + +### 对错反馈的应用(按选择状态包 Transform) +```dart +if (isRight) { + body = AnimatedBuilder( + animation: _correctScale, + child: body, + builder: (context, child) => Transform.scale(scale: _correctScale.value, child: child), + ); +} +if (isWrong) { + body = AnimatedBuilder( + animation: _wrongShake, + child: body, + builder: (context, child) => + Transform.translate(offset: Offset(_wrongShake.value, 0), child: child), + ); +} +``` + +--- + +## 五、踩坑记录 + +1. **AnimationController 必须 dispose**,且页面必须 `with SingleTickerProviderStateMixin`(否则报 Ticker 错误); +2. **隐式动画一定要写 `duration`**,漏了就瞬变没有过渡; +3. **AnimatedBuilder 的 builder 里别放不变子树**,用 `child` 参数缓存(否则每帧重建,性能浪费); +4. **shouldRepaint 要正确比较参数**:不重绘或一直重绘都是错; +5. 多个 controller 用 `TickerProviderStateMixin`(多 Ticker),单个用 Single 版; +6. 同屏多个 FloatingActionButton 要 `heroTag: null`(见 list_demo),Hero tag 全局必须唯一,重了会断言崩溃。 + +--- + +## 六、扩展思考 + +- 本项目 exam 答题页的"选对弹跳/选错抖动"就是本页思路的真实落地; +- 显式动画进阶:`AnimationController.repeat()` 无限循环(loading 旋转)、`addStatusListener` 监听完成回调; +- 隐式动画家族:AnimatedOpacity / AnimatedSwitcher(切换动画)/ AnimatedPositioned / AnimatedAlign —— 都是"属性变了自动补帧"; +- 性能纪律:动画组件尽量用 Transform(不触发 layout),别动 width/height 频繁重建。 diff --git a/docs/demos/audio_player_demo.md b/docs/demos/audio_player_demo.md new file mode 100644 index 0000000..ed2f88b --- /dev/null +++ b/docs/demos/audio_player_demo.md @@ -0,0 +1,118 @@ +# 【Demo】音频播放(AudioPlayerCard 控制条组件) + +> 源文件:`lib/pages/demo/audio_player_demo.dart` | 分类:组件封装 +> 一句话:传一个音频地址,得到一个完整控制条 —— 播放/暂停、进度拖动、倍速、循环。 + +--- + +## 一、这个 Demo 解决什么问题 + +课程详情页里常有"音频小节"。本页演示 `AudioPlayerCard`(`lib/core/components/audio_player_card.dart`)的用法: + +```dart +const AudioPlayerCard( + url: _audioUrl, // 网络源(也可以传 asset 本地源) + title: '网络测试音频(约 6 分钟)', + autoPlay: true, // 进页自动播放 +) +``` + +核心思想:**AudioPlayerCard 只是"控制条",不是整页** —— 它设计成可嵌进任意列表/详情页(列表项、课程小节卡片都能放)。 + +> 对照 Vue:相当于"封装好的 `` 组件",内部用 just_audio 插件实现。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: AudioPlayerDemoPage())); +``` + +- 进页自动播放(网络流约 6 分钟); +- 体验:播放/暂停、拖动进度(网络流拖动后出现"缓冲中 X%")、倍速切换、循环; +- 切后台自动暂停(组件内部处理,页面无需操心)。 + +> 代码里注释掉的本地源 `assets/audio/sample-30s.mp3`:30 秒本地音频,seek 秒响应(本地文件跳播无需重新缓冲)。 + +--- + +## 三、核心知识点(新手向) + +### 1. 网络音频能否"拖动进度",取决于服务器是否支持 HTTP Range +音频拖到中间播放,播放器会向服务器请求**字节范围**(Range 请求)。支持则秒跳,不支持则跳不了: + +``` +curl -s -D - -o NUL -H "Range: bytes=0-1023" <音频url> +``` + +| 响应头 | 含义 | +|---|---| +| `HTTP/1.1 206 Partial Content` | ✅ 支持,可拖动 | +| `HTTP/1.1 200 OK` | ❌ 不支持,seek 会被重置回起点 | + +> 本项目踩过的坑:samplelib.com 实测**不支持 Range**(返回 200),点进度条后位置被重置回起点; +> 而且第二次打开有系统缓存 → 看似正常(会误导排查方向!)。现用 SoundHelix(curl 实测 206)。 +> **接真实课程音频前,务必先 curl 验收服务器的 Range 支持。** + +### 2. 网络流 seek 后的"缓冲中 X%" +进度条上的**浅色区域 = 已缓冲部分**;网络流 seek 后会有一次"缓冲中 X%"——这是 ExoPlayer 的正常机制,不是卡死。 +本地文件没有这个问题,所以本地 demo 拖动"秒响应"。 + +### 3. 切后台自动暂停 +组件内部监听应用生命周期(AppLifecycleListener),切后台自动暂停——用户听一半切走,回来继续,不用业务页管。 + +--- + +## 四、关键代码剖析 + +```dart +class AudioPlayerDemoPage extends StatelessWidget { + const AudioPlayerDemoPage({super.key}); + + // 【临时调试】网络源:观察 seek 后进度条的"缓冲区域 + 缓冲中 X%" + static const String _audioUrl = + 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('音频播放'), centerTitle: true), + body: Padding( + padding: EdgeInsets.all(16.w), + child: Column( + children: [ + const AudioPlayerCard( // ← 一行引入,全部能力由组件提供 + url: _audioUrl, + title: '网络测试音频(约 6 分钟)', + autoPlay: true, + ), + ... + ], + ), + ), + ); + } +} +``` + +页面本身是 `StatelessWidget`——**音频状态全部在组件内部**(状态封装是组件化的精髓)。 + +--- + +## 五、踩坑记录 + +1. **服务器不支持 HTTP Range 时 seek 会被重置**,且第二次打开有系统缓存会"假装正常"——验收必须用 curl 看 `206`; +2. AudioPlayerCard 是**控制条组件,不是整页**——别拿它当页面用,它设计来嵌进列表/详情; +3. 网络流 seek 后有"缓冲中 X%"是正常机制(ExoPlayer),浅色区域 = 已缓冲; +4. 本地 asset 源与网络 url 源二选一传参(`asset:` 或 `url:`),别两个都传; +5. 切后台自动暂停是组件内部处理的,业务页不要重复实现。 + +--- + +## 六、扩展思考 + +- 组件内部用 just_audio 插件:想了解播放器原理(音源切换、事件流)直接读 `lib/core/components/audio_player_card.dart`; +- 富文本里的 `` 标签会自动替换成 AudioPlayerCard,见 [rich_text_demo.md](rich_text_demo.md); +- 进阶"列表连续播放":在 AudioPlayerCard 上加 `setAudioSources` + 上一首/下一首(just_audio 原生支持); +- 真实项目:把 `_audioUrl` 换成课程接口返回的音频地址即可,组件层不用动。 diff --git a/docs/demos/detail_page.md b/docs/demos/detail_page.md new file mode 100644 index 0000000..7f1bfba --- /dev/null +++ b/docs/demos/detail_page.md @@ -0,0 +1,86 @@ +# 【Demo】详情页:参数接收的两种通道 + +> 源文件:`lib/pages/demo/detail_page.dart` | 分类:路由 +> 一句话:构造参数与 ModalRoute arguments 双通道 —— 谁来了用谁,缺谁兜底。 + +--- + +## 一、这个 Demo 解决什么问题 + +同一个详情页,可能被两种方式打开: +- **方式 1**:`push` 构造传参(`DetailPage(name: '张三', age: 18)`); +- **方式 3**:`pushNamed` 命名路由 + arguments(`{'name': '李四', 'age': 25}`)。 + +页面要**同时兼容两种入口**:优先用 arguments 里的值,没有就用构造参数兜底。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const RouterApp()); +``` +首页分别用方式 1 / 方式 3 跳进来,观察两种取参路径的效果。 + +--- + +## 三、核心知识点(新手向) + +### 1. 构造参数(类型安全) +```dart +class DetailPage extends StatelessWidget { + const DetailPage({super.key, this.name = '默认', this.age = 0}); + + final String name; + final int age; + ... +} +``` +- 带默认值 → 不传参也能打开,不会崩; +- 类型安全:传错类型编译期报错。 + +### 2. 命名路由 arguments(运行期 Map) +```dart +final args = ModalRoute.of(context)?.settings.arguments; +final argMap = args is Map ? args : null; +``` +- `ModalRoute.of(context)` 拿到当前路由 → `.settings.arguments` 取出 pushNamed 传的参数; +- arguments 是 dynamic,**必须判空强转**: + - 不是 Map(或没传)→ `argMap` 为 null; + - `argMap?['name']` 取不到时用 `?? name` 回退构造参数。 + +### 3. 双通道合并取值(本页精髓) +```dart +Text('姓名:${argMap?['name'] ?? name}'), +Text('年龄:${argMap?['age'] ?? age}'), +``` +"arguments 优先,构造参数兜底"——两种入口都兼容,缺参不崩。 + +--- + +## 四、关键代码剖析 + +```dart +// 手动 pop(AppBar 的返回箭头也是 pop,效果一样) +OutlinedButton( + onPressed: () => Navigator.pop(context), + child: const Text('返回上一页'), +) +``` + +--- + +## 五、踩坑记录 + +1. `arguments` 是 dynamic,**直接用会崩**(`args['name']` 对非 Map 类型运行期报错)——先 `is Map` 判断; +2. `ModalRoute.of(context)?.settings.arguments` 有 `?`,路由可能为 null; +3. 构造参数给默认值,保证"零参数也能打开页面"(debug 直达页面时很有用); +4. 命名路由注册在 [router_demo.md](router_demo.md) 的 routes 表里。 + +--- + +## 六、扩展思考 + +- 选择器页 pop 带值见 [picker_page.md](picker_page.md); +- 参数多了建议封装成强类型对象(`class DetailArgs`),避免 Map 满天飞; +- 真实项目(go_router)用 `GoRoute` 的 `arguments` 或 `extra` 传对象,原理一致。 diff --git a/docs/demos/dio_demo.md b/docs/demos/dio_demo.md new file mode 100644 index 0000000..6b39d73 --- /dev/null +++ b/docs/demos/dio_demo.md @@ -0,0 +1,129 @@ +# 【Demo】Dio 网络请求(封装 + 三态 UI) + +> 源文件:`lib/pages/demo/dio_demo.dart` | 分类:网络 +> 一句话:GET/POST/报错一次跑通,学"统一请求入口 + 加载/成功/失败三态 UI"的页面范式。 + +--- + +## 一、这个 Demo 解决什么问题 + +新手第一次接接口时的完整范式: +1. 请求期间要显示 loading、按钮要禁用(防重复点击); +2. 错误要统一翻译成中文提示(封装层做,页面不碰底层细节); +3. **await 之后 setState 前必须判 mounted**(用户可能已切走页面)。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: DioDemoPage())); +``` + +4 个按钮: +- GET 单条:`PostApi.fetchPost(1)`; +- GET 列表:`PostApi.fetchPosts(userId: 1)`(jsonplaceholder mock); +- POST 新建:`PostApi.createPost(...)`; +- 故意报错:请求不存在的路径,看统一错误提示。 + +请求/响应日志在控制台打印(`DioClient` 拦截器里 debugPrint 完整 URL)。 + +--- + +## 三、核心知识点(新手向) + +### 1. 项目的网络分层(重点!) +``` +页面(本 Demo)→ PostApi(业务接口,只定义"有哪些接口") + → DioClient(封装层:baseUrl/超时/拦截器/错误翻译) + → Dio(底层库) +``` +- `PostApi` 只关心业务:`fetchPost(1)`、`fetchPosts(userId: 1)`; +- `DioClient` 统一处理:日志、超时、把 `DioException` 翻译成中文 `ApiException`; +- 页面 **只 catch `ApiException`**,不用关心 Dio 的细节类型。 + +### 2. 统一请求入口 _run(页面范式) +```dart +Future _run(Future Function() action) async { + setState(() { + _loading = true; + _error = null; + }); + try { + await action(); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _error = '请求失败:${e.message}'); + } catch (e) { + if (!mounted) return; + setState(() => _error = '未知异常:$e'); + } finally { + if (mounted) setState(() => _loading = false); // 无论成败都复位 + } +} +``` +- **`finally` 里复位 `_loading`**:成功/失败都要恢复,忘记写就会永远转圈; +- 每个具体请求只是往 `_run` 里传一个闭包,**loading 逻辑只写一遍**。 + +### 3. 三态 UI +```dart +// 加载中:转圈 +if (_loading) Center(child: CircularProgressIndicator()); + +// 失败:红底错误卡 +if (_error != null) + Container( + color: const Color(0xFFFDECEA), + child: Text(_error!, style: const TextStyle(color: Color(0xFFD32F2F))), + ); + +// 成功:结果卡(SelectableText 可复制) +Container( + child: SelectableText(_resultBody.isEmpty ? '(暂无数据)' : _resultBody), +) +``` + +### 4. 按钮防重 +```dart +FilledButton.icon( + onPressed: _loading ? null : _fetchOne, // loading 时禁用 + ... +) +``` + +--- + +## 四、关键代码剖析 + +```dart +/// GET 单条 +Future _fetchOne() async { + await _run(() async { + final post = await PostApi.fetchPost(1); + if (!mounted) return; + setState(() { + _resultTitle = 'GET /posts/1 成功'; + _resultBody = 'id: ${post.id}\nuserId: ${post.userId}\ntitle: ${post.title}'; + }); + }); +} +``` + +--- + +## 五、踩坑记录 + +1. **await 之后 setState 前必须 `if (!mounted) return`** —— 异步期间页面可能已销毁,直接 setState 会报错; +2. **catch 顺序**:先 `on ApiException`(业务错误),再 `catch (e)`(兜底未知异常); +3. 别在页面里直接 catch `DioException` —— 封装层的翻译就白做了; +4. loading 复位放 `finally`,不是 try 里 —— 否则异常路径永远转圈; +5. 真实项目 URL 用相对路径(baseUrl 在 DioClient 统一配),别在页面写死域名。 + +--- + +## 六、扩展思考 + +- 生产级能力(取消/进度/重试)见 [dio_pro_demo.md](dio_pro_demo.md); +- 上传文件(FormData + onSendProgress)见 [oss_upload_demo.md](oss_upload_demo.md); +- 页面更进阶的写法:用 Riverpod `AsyncNotifierProvider` 管理请求状态,AsyncValue 自带 loading/error/data 三态(见 exam 架构文档); +- 错误重试策略:`DioClient` 已内置指数退避重试(只对网络层错误),开关见 dio_pro_demo。 diff --git a/docs/demos/dio_pro_demo.md b/docs/demos/dio_pro_demo.md new file mode 100644 index 0000000..3ee7083 --- /dev/null +++ b/docs/demos/dio_pro_demo.md @@ -0,0 +1,137 @@ +# 【Demo】Dio 生产级能力(进度 / 取消 / 重试) + +> 源文件:`lib/pages/demo/dio_pro_demo.dart` | 分类:网络 +> 一句话:下载进度条、页面销毁自动取消请求、网络错误自动重试 —— 生产环境三件套。 + +--- + +## 一、这个 Demo 解决什么问题 + +[dio_demo.md](dio_demo.md) 讲了基础请求,本页升级到生产级场景: +1. **下载进度**:`onReceiveProgress` 实时百分比 + 进度条; +2. **请求取消**:用户手动取消 + **页面销毁自动取消**(防泄漏); +3. **失败重试**:网络层错误自动指数退避重试 N 次(控制台看 ↻ 日志)。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: DioProDemoPage())); +``` + +- "开始下载":下载约 2MB 的视频文件,进度条动起来; +- "取消下载":手动中断; +- "重试演示":请求不存在的域名 → DNS 解析失败 → 自动重试 3 次(指数退避)→ 抛"网络异常"; +- 顶部开关控制是否启用重试,方便对比。 + +--- + +## 三、核心知识点(新手向) + +### 1. 为什么需要 CancelToken +页面销毁但请求还在跑 → Future 没人接,响应回调还可能在 setState(对已销毁的 State setState 会报错/泄漏)。 + +做法: +```dart +CancelToken? _token; + +Future _startDownload() async { + final token = CancelToken(); // 每个请求新建一个(取消后不可复用!) + _token = token; + try { + await DioClient.instance.get( + _bigUrl, + cancelToken: token, + onReceiveProgress: (received, total) { + if (!mounted || token.isCancelled) return; + setState(() => _progress = total <= 0 ? 0 : received / total); + }, + retries: _retryEnabled ? 2 : 0, + ); + } on ApiException catch (e) { + // 取消也会走到这里("请求已取消"),统一复位 UI + if (!mounted) return; + setState(() { _log += '✖ ${e.message}\n'; _downloading = false; }); + } +} + +@override +void dispose() { + _token?.cancel('页面已销毁'); // 页面销毁 → 自动取消在途请求(核心防泄漏点) + super.dispose(); +} +``` + +### 2. 进度回调的三个坑 +```dart +onReceiveProgress: (received, total) { + if (!mounted || token.isCancelled) return; // ① 回调频率高,必判 mounted + setState(() => _progress = total <= 0 ? 0 : received / total); // ② total<=0 除零兜底 +}, +``` +- `total <= 0`:响应没有 Content-Length 时 total 为 -1,直接除会算出负数进度; +- 进度回调每秒触发很多次,每次 setState 前判 mounted 是性能与安全双重保险。 + +### 3. 重试为什么只对"网络层错误"生效 +| 错误类型 | 可重试? | 原因 | +|---|---|---| +| connectionError(断网/域名解析失败)、超时、闪断 | ✅ | 网络恢复后大概率成功 | +| 主动取消(用户意图) | ❌ | 用户不要了还重试 = 反人性 | +| badResponse(4xx/5xx) | ❌ | 重试大概率同样失败,POST 还会**重复提交数据** | + +这是生产级的取舍,见 `dio_client.dart` 的 `_canRetry`。 + +### 4. Web 调试的 CORS 坑 +- `application/json` 是**非简单头**,跨域 GET 会先发 **OPTIONS 预检**,静态服务器不支持就 405 被拦; +- 下载媒体/图片时用 headers 覆盖成 **text/plain 简单头**,浏览器不再预检直接放行(真机无此问题)。 + +```dart +headers: const {'Content-Type': 'text/plain'}, // Web 调试必加! +``` + +--- + +## 四、关键代码剖析 + +```dart +// 手动取消 +void _cancelDownload() { + _token?.cancel('用户点击取消'); + setState(() => _log += '⏹ 已发起取消,等待请求中断...\n'); +} +``` +取消后请求抛 `DioExceptionType.cancel` → 封装层翻译成 `ApiException('请求已取消')` → 走 onError 分支。 + +```dart +// 重试演示:Stopwatch 统计总耗时 +final stopwatch = Stopwatch()..start(); +try { + await DioClient.instance.get('https://no-such-domain-abcxyz.com/api/test', + retries: _retryEnabled ? 3 : 0); +} on ApiException catch (e) { + stopwatch.stop(); + setState(() => _log = '✖ ${e.message}\n⏱ 共耗时 ${stopwatch.elapsed.inMilliseconds}ms\n' + '(重试开关:${_retryEnabled ? '开' : '关'})'); +} +``` + +--- + +## 五、踩坑记录 + +1. **`onReceiveProgress` 里 setState 前必须判 mounted**(回调频率高,页面销毁后必崩); +2. **`total <= 0` 时进度除零**,要兜底成 0; +3. **CancelToken 一旦取消就不能复用**,每次请求要新建; +4. **取消走 onError 分支**(DioExceptionType.cancel),不是"不走回调"——UI 复位逻辑要放对地方; +5. Web 跨域下载:Content-Type 改 text/plain 绕过预检; +6. 上传进度同理:`onSendProgress: (sent, total)`,笔记图片上传的下一步就接它。 + +--- + +## 六、扩展思考 + +- 重试策略进阶:`RetryInterceptor` 的 retryableExtra / 重试次数按接口配置; +- 下载文件落盘:`dio` 的 `download()` + 进度回调 + `path_provider` 存沙盒; +- 上传场景:[oss_upload_demo.md](oss_upload_demo.md) 的 OSS 直传 PUT 就带 onSendProgress; +- 页面级取消还可以用 `DisposeBag` 模式统一管理多个 token(本项目单 token 够用)。 diff --git a/docs/demos/exam_demo.md b/docs/demos/exam_demo.md new file mode 100644 index 0000000..7f13a2b --- /dev/null +++ b/docs/demos/exam_demo.md @@ -0,0 +1,109 @@ +# 【Demo】试卷练习入口(大数据滑动验证页) + +> 源文件:`lib/pages/demo/exam_demo.dart` | 分类:考试(exam) +> 一句话:试卷练习的入口页 —— 选题目数量(50/100/200)→ 进入真实考试页,验证 PageView 大数据滑动不卡。 + +--- + +## 一、这个 Demo 解决什么问题 + +真实考试页(`lib/features/exam/exam_page.dart`)是**本项目的核心功能**,完整架构见 +[exam_architecture.md](../exam_architecture.md)(建议先读)。本页是它的**演示入口**,回答三个问题: + +1. **100 道题滑动卡不卡?** —— PageView.builder 懒加载:只构建视口 ±1 页,滑出的页面自动回收; +2. **四种题型怎么共存?** —— 按 `question.type` switch 分发到单选/多选/判断/解答卡片; +3. **答一半退出,进度还在吗?** —— Riverpod(内存主状态)+ Isar(AnswerRecord 备份层),重进自动恢复。 + +> 对照 Vue:对标 swiper 大数据瓶颈——Vue 会把所有 DOM 挂上,Flutter 的 PageView.builder 只构建看得见的 ±1 页。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: ExamDemoPage())); +``` + +1. **SegmentedButton 选数量**(50/100/200 题)→ 点"开始练习"; +2. 快速左右滑动感受流畅度(`allowImplicitScrolling` 预构建相邻页); +3. 答几题 → 退出 → 重新进入 → SnackBar 提示"已恢复上次答题进度(X 题),定位到第 Y 题"; +4. 交卷 → 自动判分(客观题)+ 按 paperId 清理本地进度。 + +--- + +## 三、核心知识点(新手向) + +### 1. 数量选择 = Riverpod 全局状态 +```dart +class ExamDemoPage extends ConsumerWidget { // ① 用 ConsumerWidget 才能 ref + ... + final size = ref.watch(paperSizeProvider); // ② watch:变化自动重建 + + SegmentedButton( + segments: [for (final s in _sizes) ButtonSegment(value: s, label: Text('$s 题'))], + selected: {size}, + onSelectionChanged: (selection) { + ref.read(paperSizeProvider.notifier).setSize(selection.first); // ③ 回调里 read + }, + ) +``` +- **build 里 `ref.watch`**(响应式重建),**回调里 `ref.read`**(只读一次不订阅)——Riverpod 的黄金规则; +- `paperSizeProvider` 是全局的:入口页选的 50 题,考试页读到的就是 50 题(跨页共享状态)。 + +### 2. 进入考试页 = 普通跳转 +```dart +Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ExamPage()), +); +``` +考试页通过同一个 `paperSizeProvider` 拿题量——**传参交给状态管理,路由只负责跳转**(对比 [router_home_page.md](router_home_page.md) 的构造传参)。 + +### 3. 三个架构要点(页面里直接写了卡片) +| 要点 | 说明 | +|---|---| +| PageView.builder 懒加载 | 只构建视口 ±1 页,滑出即回收;`allowImplicitScrolling` 预构建相邻页 → 100 题滑动不掉帧 | +| 题型分发(QuestionCard) | 按 `question.type` switch 到单选/多选/判断/解答视图;组件**纯展示不碰 provider**,ExamPage 唯一桥接 Riverpod | +| 续答持久化(Riverpod + Isar) | 答案主状态在内存,每次作答异步写 AnswerRecord(Isar);退出重进自动恢复,交卷后按 paperId 清理 | + +--- + +## 四、关键代码剖析 + +```dart +static const List _sizes = [50, 100, 200]; + +// 入口页数据流: +// SegmentedButton 选择 → paperSizeProvider(全局) +// → ExamPage 同读该 provider 生成 N 道题 → PageView.builder 懒加载渲染 + +FilledButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ExamPage()), + ); + }, + icon: const Icon(Icons.play_arrow), + label: Text('开始练习($size 题)'), // 直接展示当前全局题量 +) +``` + +`_ArchPoint` 是纯展示卡片(图标 + 标题 + 描述),无状态无逻辑——学习型页面的"知识卡片"惯例。 + +--- + +## 五、踩坑记录 + +1. **ConsumerWidget 才能用 ref**:普通 StatelessWidget 里没有 `ref` 参数(要自己 `Consumer` 包一层); +2. `ref.read` 只用在**回调**里,build 里用 `ref.watch`——反了会导致不响应或多余重建; +3. 本页是入口,**真正的考试逻辑在 ExamPage**(`lib/features/exam/exam_page.dart`),别在本页找答案判分逻辑; +4. Web 调试时 Isar 为空实现 → 续答自动降级为纯内存(不落盘),这是设计好的,不是 bug; +5. 想快速体验"恢复进度",答几题后**直接退出页面**(系统返回),而不是杀进程——内存态还在。 + +--- + +## 六、扩展思考 + +- **完整架构必读**:[exam_architecture.md](../exam_architecture.md)(零、新手前置 → 一到九章,含 Riverpod/Isar 教程); +- 题干是 HTML 富文本,渲染方式见 [rich_text_demo.md](rich_text_demo.md); +- 题目/答案的本地持久化模式(Store + watch)见 [isar_watch_demo.md](isar_watch_demo.md); +- 答题记录的"接口 + 本地"双通道思路见 [wrong_flow_demo.md](wrong_flow_demo.md)。 diff --git a/docs/demos/form_demo.md b/docs/demos/form_demo.md new file mode 100644 index 0000000..b846c10 --- /dev/null +++ b/docs/demos/form_demo.md @@ -0,0 +1,186 @@ +# 【Demo】Form 表单校验(7 个知识点) + +> 源文件:`lib/pages/demo/form_demo.dart` | 分类:基础布局 +> 一句话:注册表单校验的完整范式 —— validator、时机、跨字段对比、协议勾选、两段式校验。 + +--- + +## 一、这个 Demo 解决什么问题 + +做注册/登录表单时新手最头疼的几件事,本页一次讲全: +必填/长度/正则/密码强度校验、确认密码对比、协议勾选校验、 +**validator 不能 await** 时怎么做异步查重、防重复提交、重置表单。 + +### 和 Vue 的对照(关键差异) +| Vue | Flutter | +|---|---| +| v-model 双向绑定 + rules 规则对象 | TextEditingController + 每个字段自带 `validator` 函数 | +| `validate()`(async-validator) | `formKey.currentState.validate()` 统一触发 | + +**Flutter 的 validator 是同步函数,返回 `String?`**: +- 返回 `null` → 校验通过; +- 返回字符串 → 错误文本(自动显示在输入框下方红字)。 + +没有"规则对象",每个字段的规则就写在它自己的 validator 里,**就近维护**。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: FormDemoPage())); +``` + +5 个输入框(用户名/手机号/邮箱/密码/确认密码)+ 协议勾选 + 提交/重置: +- 不填就提交 → 红字提示;填错即时提示(onUserInteraction); +- 提交通过 → 模拟 1 秒异步查重 → "注册成功"; +- 重置 → 清空所有输入和校验状态。 + +--- + +## 三、核心知识点(新手向) + +### 1. Form 的骨架 +```dart +final GlobalKey _formKey = GlobalKey(); // Form 的唯一钥匙 + +Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, // 校验时机 + child: Column(children: [ TextFormField(validator: ...), ... ]), +) + +// 提交 / 重置都靠这把钥匙 +_formKey.currentState!.validate(); +_formKey.currentState?.reset(); +``` + +### 2. 三种校验时机(AutovalidateMode) +| 模式 | 行为 | 建议 | +|---|---|---| +| `disabled`(默认) | 只在点提交 validate() 时校验 | 简单表单 | +| `always` | build 时立刻校验 | **慎用**:空表单满屏红字 | +| `onUserInteraction` | 用户碰过的字段才即时校验 | **推荐** | + +### 3. validator 写法范式 +```dart +// 必填 + 长度 +String? _validateName(String? v) { + final s = v?.trim() ?? ''; + if (s.isEmpty) return '请输入用户名'; + if (s.length < 2 || s.length > 8) return '用户名需 2-8 个字符'; + return null; +} + +// 正则全匹配:^...$ 必须写!不然 11 位之后还能续数字 +String? _validatePhone(String? v) { + final s = v?.trim() ?? ''; + if (s.isEmpty) return '请输入手机号'; + if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(s)) return '手机号格式不正确'; + return null; +} + +// 跨字段对比:validator 里读另一个 controller +String? _validatePwd2(String? v) { + if (v == null || v.isEmpty) return '请再次输入密码'; + if (v != _pwdC.text) return '两次密码不一致'; + return null; +} +``` + +### 4. 两段式校验(重点!validator 不能 await) +```dart +// ① 同步校验:所有 validator 逐个执行 +if (!_formKey.currentState!.validate()) { + _toast('有字段未通过校验,请检查红字提示'); + return; +} +// ② 同步通过 → 再单独做异步业务校验(查重/验证码) +setState(() => _submitting = true); +await Future.delayed(const Duration(seconds: 1)); // 模拟查重请求 +if (!mounted) return; // 异步后必须判 mounted 才能用 context +``` + +### 5. 协议勾选:FormField\ 校验"非输入型"字段 +```dart +FormField( + initialValue: _agreed, + validator: (v) => (v ?? false) ? null : '请先勾选同意协议', + builder: (field) { + return Column(children: [ + InkWell( + onTap: () { + field.didChange(!(field.value ?? false)); // 更新 FormField 自身 + setState(() => _agreed = field.value ?? false); // 同步外部镜像 + }, + child: ..., + ), + // 注意:自定义 FormField 的 errorText 要自己在 builder 里渲染! + if (field.hasError) + Text(field.errorText!, style: const TextStyle(color: Colors.red)), + ]); + }, +) +``` + +### 6. 防重复提交 + 重置 +```dart +// 提交中按钮禁用(onPressed: null 自动置灰 + 换 loading 转圈) +FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting ? CircularProgressIndicator() : const Text('提交'), +) + +// 重置:reset() 只清校验错误和 FormField 状态,不碰 controller.text! +void _reset() { + _formKey.currentState?.reset(); + _nameC.clear(); // 清空输入要手动 clear() + ... +} +``` + +--- + +## 四、关键代码剖析 + +通用输入框抽取(本页把 label/hint/图标/校验规则全部参数化,避免 5 个输入框复制粘贴): + +```dart +Widget _buildField({ + required String label, + required TextEditingController controller, + required String? Function(String?) validator, + TextInputType? keyboardType, +}) { + return TextFormField( + controller: controller, + keyboardType: keyboardType, + validator: validator, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + border: const OutlineInputBorder(), + ), + ); +} +``` + +--- + +## 五、踩坑记录 + +1. **`formState.reset()` 不碰 `controller.text`** —— 清空输入必须手动 `clear()`; +2. **validator 是同步函数,不能 await** —— 异步校验(查重/验证码)放 validate() 通过之后单独做; +3. **正则要 `^...$` 全匹配**,否则 `RegExp(r'1[3-9]\d{9}')` 能匹配"123456789012345"; +4. 所有 controller 都要 dispose(本页 5 个); +5. 加了 `.w/.h/.sp` 的 widget 不能 `const`; +6. 异步(await)之后用 context 必须先 `if (!mounted) return`。 + +--- + +## 六、扩展思考 + +- 校验规则多了以后,可以把 validator 抽成独立函数文件(如 `lib/core/validators/`),表单只负责组装; +- 真实注册流程:本页"模拟查重"处换成 `DioClient` 请求(见 [dio_demo.md](dio_demo.md)); +- 密码强度可以升级为正则组合:`(?=.*[A-Za-z])(?=.*\d)` 就是"必须同时含字母和数字"的零宽断言写法; +- 表单 + 状态管理:把 controller 和提交逻辑挪进 Riverpod `NotifierProvider`,页面只剩纯 UI(见架构文档的 ExamPage 分层思想)。 diff --git a/docs/demos/grid_demo.md b/docs/demos/grid_demo.md new file mode 100644 index 0000000..8591b68 --- /dev/null +++ b/docs/demos/grid_demo.md @@ -0,0 +1,92 @@ +# 【Demo】GridView 网格布局(三种构造) + +> 源文件:`lib/pages/demo/grid_demo.dart` | 分类:基础布局 +> 一句话:九宫格/相册/功能宫格都靠它,三种构造方式一次看懂。 + +--- + +## 一、这个 Demo 解决什么问题 + +"功能宫格(4×2 图标)"、"相册缩略图(3 列)"、"课程卡片墙"这类**多列等宽网格**布局。 +GridView 就是 Flutter 的网格容器,支持**懒加载**,大量数据不卡。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: GridDemoPage())); +``` + +一屏并排演示三种构造: +1. `GridView.count`:固定 3 列,写死 children; +2. `GridView.builder`:4 列,`itemCount: 100` 懒加载; +3. `GridView.extent`:每项最多 80 宽,**屏幕越宽列数自动变多**。 + +--- + +## 三、核心知识点(新手向) + +### 1. 三个常用构造怎么选 +| 构造 | 特点 | 适用 | +|---|---|---| +| `GridView.count` | 固定列数,children 一次性全创建 | 少量固定网格(< 30) | +| `GridView.builder` | 懒加载,`itemCount` + `itemBuilder` | **大量数据首选**(100+ 条) | +| `GridView.extent` | 按"每项最大宽度"自适应列数 | 响应式场景(Pad 变列数) | + +### 2. 核心参数 +```dart +GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, // 列数 + mainAxisSpacing: 8.w, // 主轴(上下行)间距 + crossAxisSpacing: 8.w, // 交叉轴(左右列)间距 + childAspectRatio: 1, // 每项 宽:高 比例,默认 1 = 正方形 + ), + itemCount: 100, + itemBuilder: (context, index) => _GridItem(index: index), +) +``` + +### 3. 重点记忆:GridView 的本质 +**GridView = CustomScrollView + 1 个 SliverGrid**(框架内部自动包的简化套餐)。 +- 页面只有网格 → `GridView.builder` 直接用; +- 页面要混排(折叠 AppBar + 网格 + 列表)→ 用 `CustomScrollView + slivers`(见 [sliver_demo.md](sliver_demo.md))。 + +--- + +## 四、关键代码剖析 + +```dart +// Demo3:extent 自适应列数 +GridView.extent( + maxCrossAxisExtent: 80.w, // 每项最多 80 宽 → 屏幕宽时自动多列 + mainAxisSpacing: 8.w, + crossAxisSpacing: 8.w, + childAspectRatio: 1, + children: [for (int i = 0; i < 12; i++) _GridItem(index: i)], +) +``` + +> 响应式网格更精细的做法见 [responsive_demo.md](responsive_demo.md): +> 用 `LayoutBuilder` 拿真实宽度,`(width / 280).floor().clamp(1, 4)` 算列数。 + +--- + +## 五、踩坑记录 + +1. **GridView 放 Column 里必须 SizedBox 定高或 Expanded**,否则报 + `Vertical viewport was given unbounded height`(和 ListView 同一个坑)。 +2. **`childAspectRatio` 太小(如 0.5)时,格子内文字会被挤压溢出** —— 调大比例或缩小字号。 +3. 本页外层 `SingleChildScrollView` 只是"展示台"(为了并排演示 3 种构造), + 真实网格页**别学这个外层** —— 网格自己就能滚。 +4. 想"网格 + 下拉刷新",用 `RefreshIndicator` 包 GridView(写法同 [list_demo.md](list_demo.md) 的列表刷新)。 + +--- + +## 六、扩展思考 + +- **`SliverGridDelegateWithMaxCrossAxisExtent`**:同 extent 效果的另一种 delegate 写法; +- 相册九宫格真实案例见 [note_picker_demo.md](note_picker_demo.md): + GridView + "加号格" + 右上角删除角标(Stack 组合),多一格用 `itemCount: _images.length + 1`; +- 性能提示:builder 系列(builder/extent.builder)是懒加载,`count` 不是 —— 大列表务必 builder。 diff --git a/docs/demos/image_demo.md b/docs/demos/image_demo.md new file mode 100644 index 0000000..2d43740 --- /dev/null +++ b/docs/demos/image_demo.md @@ -0,0 +1,212 @@ +# 【Demo】Image 图片组件(四种来源 + BoxFit + 缓存 + 错误兜底) + +> 源文件:`lib/pages/demo/image_demo.dart` | 分类:组件封装 +> 一句话:网络图(带缓存)/ 本地资源图 / 圆角图 / 圆形头像 -- 图片加载的四个必会姿势。 + +--- + +## 一、这个 Demo 解决什么问题 + +图片是 App 最高频的组件,但新手常踩:加载失败白屏、图片变形、圆角/圆形不会切、网络图每次重下载不缓存。 +本页用四个区块讲清: + +| 区块 | 用法 | 知识点 | +|---|---|---| +| ① 网络图片(带缓存) | `CachedNetworkImage` | `BoxFit.cover` + `progressIndicatorBuilder` 下载进度 + `errorWidget` 兜底 | +| ② 本地资源图 | `Image.asset` | 必须在 pubspec.yaml 注册 assets | +| ③ 圆角网络图 | `ClipRRect` 包 `CachedNetworkImage` | 图片本身没变,只是画出来带圆角 | +| ④ 圆形头像 | `CircleAvatar` + `CachedNetworkImageProvider` | `backgroundImage` 走缓存 + 失败时显示首字母 | + +> 对照 Vue:`` 的 Flutter 版本,但多了"裁切模式"、"磁盘缓存"和"错误兜底"这三个必须处理的点。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: ImageDemoPage())); +``` + +- ① 首次加载显示下载进度条;再次进入本页(或同 URL 出现在列表页)秒出 -- 走本地缓存,不重新下载; +- ② 本地 logo 图等比完整显示; +- ③ 圆角大图(radius 60 接近胶囊),加载中转圈; +- ④ 圆形头像:加载失败时显示"头"字兜底。 +- 断网/改错地址 -> 所有网络图都显示"图片加载失败"占位图(不白屏)。 + +--- + +## 三、核心知识点(新手向) + +### 1. 图片来源的几种方式 +```dart +CachedNetworkImage(imageUrl: url) // 网络图片 + 本地缓存(生产推荐) +CachedNetworkImageProvider(url) // 同上,但返回 ImageProvider(头像场景) +Image.network(url) // 网络图片(无缓存,每次重下载,仅作对比) +Image.asset('assets/...') // 本地资源(先注册 pubspec.yaml) +Image.file(File(...)) // 本地磁盘文件(需 path_provider,本页不演示) +Image.memory(bytes) // 内存字节流(base64 等,见 share_demo 截图预览) +``` + +### 2. 为什么用 CachedNetworkImage 而不是 Image.network +`Image.network` 每次都重新发 HTTP 请求下载图片,没缓存、列表页性能差、流量浪费。 +`cached_network_image`(baseflow 维护,6.9k likes)底层用 `flutter_cache_manager` 把图片写入 +本地缓存目录,二次加载直接读本地文件,对标 Web 端浏览器 HTTP 缓存。它还提供三段式回调, +比 `Image.network` 的 `loadingBuilder`(依赖 `ImageChunkEvent`,常常拿不到进度)更可靠: + +```dart +CachedNetworkImage( + imageUrl: _netUrl, + fit: BoxFit.cover, + // 加载中占位 / 下载进度(两者二选一,progressIndicatorBuilder 更直观) + progressIndicatorBuilder: (context, url, downloadProgress) => + CircularProgressIndicator(value: downloadProgress.progress), + // 失败兜底:断网/404/格式错 -> 占位图,不白屏 + errorWidget: (context, url, error) => _buildErrorBox(), +) +``` + +> 注意:`placeholder` 和 `progressIndicatorBuilder` **二选一**,后者会覆盖前者。 +> `downloadProgress.progress` 是 `double?`(总大小未知时为 null),需判空再赋给 `value`。 + +### 2.1. 新增依赖后必须 Hot Restart(原生端常见“假死 loading”根因) +现象:你改了 `pubspec.yaml`、新增插件后,只点 Hot Reload,Android/iOS 模拟器里可能出现 +`CachedNetworkImage` 一直处于 loading(不进 errorWidget、不出图);但完整重启后恢复正常。 +原因:原生插件注册发生在启动阶段(GeneratedPluginRegistrant),Hot Reload 不会重新执行注册流程。 +解决:停止运行 → 重新 `flutter run` / Hot Restart 一次。 + +### 3. 头像场景用 CachedNetworkImageProvider +```dart +CircleAvatar( + backgroundImage: CachedNetworkImageProvider(_netUrl), // 同样走缓存 + onBackgroundImageError: (error, stackTrace) {}, // 失败不崩 + child: Text('头'), // 背景加载失败时显示首字母 +) +``` +`CachedNetworkImage` 是 Widget,适合直接渲染;`CachedNetworkImageProvider` 是 ImageProvider, +适合塞给 `CircleAvatar.backgroundImage`、`BoxDecoration.image` 等需要 ImageProvider 的场景。 + +### 4. BoxFit 裁切模式速记 +| 值 | 行为 | 用途 | +|---|---|---| +| `cover` | 等比缩放填满并**裁掉多余** | 头像/封面(最常用) | +| `contain` | 等比缩放**完整显示**(有留白) | logo、二维码 | +| `fill` | **拉伸填满**(会变形) | 少用 | +| `fitWidth` / `fitHeight` | 按宽/高撑满 | 特殊场景 | + +### 5. 错误兜底:不白屏 +```dart +CachedNetworkImage( + imageUrl: _netUrl, + width: 300.w, + height: 180.h, + fit: BoxFit.cover, + errorWidget: (context, url, error) => + _buildErrorBox(width: 300.w, height: 180.h), // 断网/404/格式错 -> 占位图 +) +``` +Flutter 3.x 网络图**务必给 `errorWidget`**,否则加载失败抛异常白屏。 +占位图 `_buildErrorBox` 接收和原图一样的宽高 -> 布局不跳变(加载中->失败,UI 不抖动)。 + +### 6. 圆角 ≠ 图片属性,是"裁剪"组件 +```dart +ClipRRect( + borderRadius: BorderRadius.circular(60.r), // 裁剪器 + child: CachedNetworkImage(...), // 图片本身没变 +) +``` + +--- + +## 四、关键代码剖析 + +```dart +class ImageDemoPage extends StatelessWidget { + const ImageDemoPage({super.key}); + + static const String _netUrl = + 'https://hx.23544.com/StaticResource/2026-07-29/ranking3Position1785292048369.png'; + + /// 加载中占位:progress 为 null 转圈,非 null 显示百分比进度条 + Widget _buildLoadingBox({ + required double width, + required double height, + required double? progress, + }) { + return Container( + width: width, + height: height, + color: const Color(0xFFF0F2F5), + child: Center( + child: progress == null + ? SizedBox( + width: 24.w, + height: 24.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24.w, + height: 24.w, + child: CircularProgressIndicator( + value: progress, strokeWidth: 2), + ), + SizedBox(height: 8.h), + Text('${(progress * 100).toInt()}%', + style: TextStyle(fontSize: 12.sp, color: Color(0xFF888888))), + ], + ), + ), + ); + } + + /// 统一的加载失败占位图(接收和原图一样的宽高 -> 布局不跳变) + Widget _buildErrorBox({required double width, required double height}) { + return Container( + width: width, + height: height, + color: const Color(0xFFF0F2F5), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.broken_image_outlined, size: 40.w, color: Color(0xFFB0B4BC)), + SizedBox(height: 8.h), + Text('图片加载失败', style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888))), + ], + ), + ), + ); + } +} +``` +- `progressIndicatorBuilder` 用 `downloadProgress.progress` 驱动进度条,二次加载命中缓存时直接跳过该回调(秒出); +- 占位图接收和原图一样的宽高 -> 加载中 -> 成功/失败,UI 不抖动。 + +--- + +## 五、踩坑记录 + +1. **网络图必须 errorWidget/errorBuilder**:加载失败(断网/404/格式错)不给兜底就白屏; + `CachedNetworkImage` 用 `errorWidget`,`Image.network` 用 `errorBuilder`; +2. **新增插件后必须 Hot Restart**:只 Hot Reload 可能导致原生插件未重新注册,表现为一直 loading; +3. **Image.asset 必须先注册**:`pubspec.yaml` 的 `assets:` 列表里没有的路径直接运行期报错; +4. Android **release 包要 INTERNET 权限**(`AndroidManifest.xml` 已加)--debug 默认有,release 没有会黑图; +5. 图片"变形"先查 `fit`:忘了写默认是 `fill` 拉伸,封面图要用 `cover`; +6. 圆角用 `ClipRRect` 包,**不是**给图片加 borderRadius(Image 没有这个属性,Container 才有); +7. `placeholder` 与 `progressIndicatorBuilder` **二选一**:同时写后者生效,前者被忽略; + `downloadProgress.progress` 是 `double?`,总大小未知时为 null,直接塞给 `CircularProgressIndicator.value` 前要判空; +8. **缓存命中后不再回调进度**:二次加载直接读本地缓存,`progressIndicatorBuilder`/`placeholder` 都不会触发, + 表现为"秒出" -- 这正是缓存生效的标志,不是没生效; +9. `Image.file` 在 Web 编译不过(`dart:io`),跨端项目用 `Image.memory` 替代(见 [note_picker_demo.md](note_picker_demo.md)); +10. `cached_network_image` 在 **Web 端缓存能力有限**(仅内存缓存,刷新即失效),原生端(Android/iOS)走磁盘缓存。 + +--- + +## 六、扩展思考 + +- 多图选择页的缩略图渲染(原生/Web 双端)见 [note_picker_demo.md](note_picker_demo.md); +- 截图字节流用 `Image.memory` 预览见 [share_demo.md](share_demo.md); +- 富文本里的 `` 渲染见 [rich_text_demo.md](rich_text_demo.md); +- 图片墙布局用 [grid_demo.md](grid_demo.md) 的 GridView;网络图片预览(双指缩放)用 `photo_view`。 diff --git a/docs/demos/input_demo.md b/docs/demos/input_demo.md new file mode 100644 index 0000000..1f75ee4 --- /dev/null +++ b/docs/demos/input_demo.md @@ -0,0 +1,109 @@ +# 【Demo】TextField 输入框(登录表单) + +> 源文件:`lib/pages/demo/input_demo.dart` | 分类:基础布局 +> 一句话:输入框 + 密码"小眼睛"切换 —— 表单最基础的零件。 + +--- + +## 一、这个 Demo 解决什么问题 + +做一个最简单的登录表单:手机号输入框 + 密码输入框(可切换明文/密文)。 +核心是搞懂 **TextEditingController** 的用法——Flutter 里输入框不叫"双向绑定", +而是"控制器持有值,代码里随时读写"。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: InputDemoPage())); +``` + +- 手机号框:弹数字键盘(`keyboardType: TextInputType.phone`); +- 密码框:右侧眼睛图标切换密文/明文; +- 点"登录":控制台打印两个输入框的内容。 + +--- + +## 三、核心知识点(新手向) + +### 1. 对照 Vue 的 v-model +| Vue | Flutter | +|---|---| +| `v-model="phone"` | `TextEditingController` + `controller.text` | +| 框架自动同步 | 手动 `controller.text` 读写 | + +Flutter 没有隐式的双向绑定:`controller.text` 改了就同步到输入框, +输入框内容变了 `controller.text` 也会读到 —— 但**没有"响应式"**: +想根据输入内容实时刷新 UI(如"输入满 11 位才能点登录"),要自己 `addListener` 或 `setState`。 + +### 2. TextEditingController 生命周期(红线) +```dart +final TextEditingController _userCtrl = TextEditingController(); + +@override +void dispose() { + _userCtrl.dispose(); // 必须在 dispose 释放,防内存泄漏 + super.dispose(); +} +``` +> 凡是用 `Controller` 命名的东西(Text/Scroll/Animation/Tab),基本都要 dispose。 + +### 3. 密码眼睛切换 +```dart +bool _showPwd = false; + +TextField( + controller: _pwdCtrl, + obscureText: !_showPwd, // 密文/明文由状态决定 + decoration: InputDecoration( + labelText: "密码", + prefixIcon: const Icon(Icons.lock), + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon(_showPwd ? Icons.visibility : Icons.visibility_off), + onPressed: () => setState(() => _showPwd = !_showPwd), + ), + ), +) +``` + +### 4. InputDecoration 常用参数 +- `labelText`:浮动标签(聚焦时缩小浮到边框上); +- `hintText`:灰色占位提示; +- `prefixIcon`:左侧图标;`suffixIcon`:右侧(按钮/图标); +- `border: OutlineInputBorder()`:带边框的圆角样式(默认是下划线)。 + +--- + +## 四、关键代码剖析 + +```dart +// 读取输入内容(点登录时) +String user = _userCtrl.text; +String pwd = _pwdCtrl.text; +if (kDebugMode) { + print("账号:$user,密码:$pwd"); // kDebugMode:只在 debug 包打印 +} +``` + +要点: +- `kDebugMode`(来自 `package:flutter/foundation.dart`)保证打印不会出现在 release 包; +- 真实项目里读取后要走校验(见 [form_demo.md](form_demo.md))和登录接口(见 [dio_demo.md](dio_demo.md))。 + +--- + +## 五、踩坑记录 + +1. **Scaffold 没有 `padding` 参数!** 外间距要写在 body 的 `Padding` 上(新手必踩)。 +2. `controller` 忘了 dispose → 内存泄漏(Flutter 分析器会提示)。 +3. `suffixIcon` 用 `IconButton` 而不要用普通 `Icon` 包 `GestureDetector` —— IconButton 自带点击区域和涟漪反馈。 +4. 想"输入即校验"别自己写监听,直接上 Form(见 form_demo)。 + +--- + +## 六、扩展思考 + +- 进阶校验:`TextFormField` + `validator`([form_demo.md](form_demo.md))—— 本项目表单校验全家桶; +- 真实登录页还要:防重复提交、加载动画、错误提示、密码安全存储(本项目用 flutter_secure_storage,见架构文档存储层); +- 输入法控制:`textInputAction: TextInputAction.next` 让回车键变成"下一项",最后一项变"完成"。 diff --git a/docs/demos/isar_watch_demo.md b/docs/demos/isar_watch_demo.md new file mode 100644 index 0000000..6116a3a --- /dev/null +++ b/docs/demos/isar_watch_demo.md @@ -0,0 +1,119 @@ +# 【Demo】Isar watch 响应式(错题本) + +> 源文件:`lib/pages/demo/isar_watch_demo.dart` | 分类:存储 +> 一句话:主动查库是"浅水区",`watch()` 订阅数据库是"深水区" —— 数据一变列表自动刷新。 + +--- + +## 一、这个 Demo 解决什么问题 + +本地数据库(Isar)的基本用法是 `findAll()` **主动查库**:数据变了要自己再查/刷新。 +但页面要的是"数据库一变,UI 自动更新"——这就是 **watch 响应式**: +`watch()` 订阅数据库,写入/删除事件**自动推送**,配合 `StreamBuilder` 自动重建列表。 + +> 对照前端:watch ≈ 前端框架的"数据源订阅"——UI 直接订阅数据库, +> 写入和展示彻底解耦,不用手动通知刷新。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: IsarWatchDemoPage())); +``` + +- 上方:输入题干 → "加入错题"(写入 Isar); +- 下方:`watchAll()` 订阅的列表 **无需任何刷新** 自动更新(`fireImmediately` 首帧先出当前数据); +- 对比区:一个"手动查询"按钮模拟浅水区做法(要自己点才更新); +- 点列表项 → 删除 → watch 同样自动收到删除事件。 + +> ⚠ Isar 仅 native 可用:Web 端本页显示降级提示,请用 Android/iOS/桌面端看效果。 + +--- + +## 三、核心知识点(新手向) + +### 1. watch 的用法 +```dart +StreamBuilder>( + // 响应式核心:数据库变化自动推送;fireImmediately 首帧先出一份当前数据 + stream: WrongQuestionStore.instance.watchAll(), + builder: (context, snapshot) { + if (snapshot.hasError) return const Text('监听出错'); + final items = snapshot.data ?? const []; + // items 变化即重建列表 + }, +) +``` +- `watchAll()` 返回 `Stream`,页面必须用 `StreamBuilder` 消费(StreamBuilder 自动管理订阅/取消); +- **`fireImmediately: false` 时首帧是空的** —— 通常要开 `true`,首帧先出一份当前数据。 + +### 2. 为什么经 store 而不是直接用 AppDatabase +Isar 集合成员只在 native 编译,**web 直接引用会编译失败**。 +`WrongQuestionStore` 做了**条件导出**(io 真实现 / web 空实现), +页面永远只面向 store,web 端用 `kIsWeb` 降级提示。 +(原理详见 `docs/exam_architecture.md` 的 Isar 教程章节。) + +### 3. 页面不 import 模型,用 dynamic 访问字段 +```dart +// item 是 WrongQuestion 对象(页面不 import 模型,用 dynamic 访问) +final title = item.title as String? ?? '无题干'; +final wrongAt = item.wrongAt as DateTime?; +``` +这样页面层与 Isar 模型层彻底解耦,web 编译也不受影响。 + +### 4. 写入必须 writeTxn +```dart +await WrongQuestionStore.instance.add( + questionId: DateTime.now().millisecondsSinceEpoch % 100000, + title: title, + wrongAnswer: '选错了', +); +``` +写操作必须 `writeTxn` 包住(store 内部已封装),页面不用管。 + +--- + +## 四、关键代码剖析 + +```dart +// 添加错题(写入 Isar → watch 自动推送 → 列表自动更新) +Future _addWrong() async { + final title = _titleC.text.trim(); + if (title.isEmpty) { /* SnackBar 提示 */ return; } + await WrongQuestionStore.instance.add(...); + _titleC.clear(); // 清空输入框(controller 直改,无需 setState) +} + +// 删除(watch 也会收到删除事件,列表自动减少) +Future _delete(dynamic item) async { + final id = item.id as int; + await WrongQuestionStore.instance.remove(id); +} + +// 手动查询(对比:非响应式,必须自己点才更新) +Future _manualQuery() async { + final all = await WrongQuestionStore.instance.findAll(); + if (!mounted) return; + setState(() => _manualCount = all.length); +} +``` + +--- + +## 五、踩坑记录 + +1. **watch 返回的是 Stream,必须配 StreamBuilder**(或手动 cancel),否则订阅泄漏; +2. **`fireImmediately: false` 时首帧为空** —— 列表会闪一下"空",通常要开 `true`; +3. **写操作必须 writeTxn 包住**(store 已处理,绕过 store 裸写会报错); +4. **Web 端无法跑 Isar**(条件导出空实现),只能真机/桌面看效果; +5. `dynamic` 访问字段要 `as` 强转或判空,别直接 `.title`(分析器不报错,运行期可能崩)。 + +--- + +## 六、扩展思考 + +- 完整链路(接口 + Isar + watch)见 [wrong_flow_demo.md](wrong_flow_demo.md); +- 条件导出 + 三件套(io/web)是本项目 web 兼容的核心手段,见架构文档"零、新手前置"; +- Isar 还有 `watchLazy()`(只通知不传数据)、`watchObject(id)`(单对象订阅); +- 进阶:`filter + sortBy + offset/limit` 组合查询,配合 watch 做"分页错题本"。 diff --git a/docs/demos/list_demo.md b/docs/demos/list_demo.md new file mode 100644 index 0000000..4a6862e --- /dev/null +++ b/docs/demos/list_demo.md @@ -0,0 +1,144 @@ +# 【Demo】ListView 列表(懒加载 / 刷新 / 触底加载 / 回顶) + +> 源文件:`lib/pages/demo/list_demo.dart` | 分类:基础布局 +> 一句话:列表是 App 的骨架 —— 本页把列表的"基础、分隔、下拉刷新、上拉加载、回到顶部"一次讲透。 + +--- + +## 一、这个 Demo 解决什么问题 + +列表占了移动 App 80% 的页面形态。本 Demo 演示列表开发的全套基础: +1. `ListView(children)` vs `ListView.builder` 的区别(重点!); +2. `ListView.separated` 自动分隔线; +3. `RefreshIndicator` 下拉刷新; +4. `ScrollController` 监听触底 → 上拉加载更多; +5. 滚动超过 200px 显示"回到顶部"悬浮按钮。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: ListDemoPage())); +``` + +- Demo1:一次性渲染的 ListView(公告 + ListTile + for 循环混装); +- Demo2:可下拉刷新、可触底加载更多、带回到顶部按钮的完整列表。 + +--- + +## 三、核心知识点(新手向) + +### 1. 最核心的区别(面试常问) +| 写法 | 行为 | 适用 | +|---|---|---| +| `ListView(children: [...])` | **一次性创建并渲染全部** item | 少量(<30)或异类组件混装 | +| `ListView.builder` | **懒加载**,只渲染可见项,滚到才创建 | 大量数据(聊天记录、长列表) | + +```dart +ListView.builder( + itemCount: _items.length, // 数据总条数(决定滚动范围) + itemBuilder: (context, index) => ListTile(...), // 按下标生成每一行 +) +``` + +### 2. 同族 API +- `ListView.builder`:纯列表,没有分隔线; +- `ListView.separated`:自动插分隔线,多一个 `separatorBuilder`(比在 item 里手动加 Divider 规范)。 + +### 3. 下拉刷新三件套 +```dart +RefreshIndicator( + onRefresh: _onRefresh, // 必须返回 Future(转圈直到 Future 完成) + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), // 内容不满一屏也能下拉! + ... + ), +) +``` + +### 4. 触底加载的原理 +`ScrollController.addListener` 监听滚动位置: +```dart +if (pos.pixels >= pos.maxScrollExtent - 200) { + _loadMore(); // 距底部 200px 时预加载 +} +``` +- `pos.pixels`:当前滚了多少; +- `pos.maxScrollExtent`:总共能滚多少; +- `hasClients`:控制器是否已挂载到滚动组件上(没挂载就访问会崩)。 + +### 5. Dart 3 记录类型(顺带学会) +```dart +List<(int, String)> _items = []; // (编号, 来源标签) 元组 +final (num, tag) = _items[index]; // 解构 +final newStart = _items.first.$1 + 100; // $1 取第一个字段 +``` + +--- + +## 四、关键代码剖析 + +### 防重入的 _loadMore +```dart +Future _loadMore() async { + if (_isLoadingMore || !_hasMore) return; // 防重入:滚动事件会高频触发 + _isLoadingMore = true; + setState(() {}); // 先刷新,显示底部 loading + + await Future.delayed(const Duration(milliseconds: 800)); // 模拟网络 + if (!mounted) return; // 异步后必须判 mounted + + setState(() { + _items = [..._items, ...List.generate(10, (i) => (start + i, '加载更多'))]; + _page++; + _hasMore = _page < 5; // 模拟:到第 5 页后没有更多 + }); + _isLoadingMore = false; +} +``` + +### 回到顶部按钮(Stack + AnimatedOpacity + IgnorePointer) +```dart +Positioned( + right: 8, + bottom: 8, + child: AnimatedOpacity( + opacity: _showBackToTop ? 1 : 0, + duration: const Duration(milliseconds: 200), + child: IgnorePointer( + // opacity: 0 的按钮还能被点到!必须 IgnorePointer 禁用点击 + ignoring: !_showBackToTop, + child: FloatingActionButton.small( + heroTag: null, // 避免多个 FAB 的 Hero 动画冲突 + onPressed: _scrollToTop, + child: const Icon(Icons.arrow_upward, color: Colors.white), + ), + ), + ), +) +``` +`_scrollToTop` = `_scrollController.animateTo(0, duration: 300ms, curve: Curves.easeOut)`。 + +> 显隐判断用 `if (shouldShow != _showBackToTop)` 而不是直接 setState —— 滚动事件每像素都触发, +> 只有状态真的变了才重建,避免性能浪费。 + +--- + +## 五、踩坑记录 + +1. **ListView 放进 Column/Row 必须给固定高度(SizedBox)或 Expanded**,否则报 + `Vertical viewport was given unbounded height` —— 列表是"无限高"的,放在"无限高"的 Column 里矛盾。 +2. **RefreshIndicator 必须配 `AlwaysScrollableScrollPhysics()`**,否则内容不满一屏时拉不动。 +3. **`ScrollController` 必须在 dispose 释放**(与 TextEditingController 同理,防内存泄漏)。 +4. **异步后 setState 前必须 `if (!mounted) return`**——await 期间用户可能已切走页面。 +5. 触底判断用 `maxScrollExtent - 200` 提前量,不然在底部"刚好"才加载会有白屏等待感。 + +--- + +## 六、扩展思考 + +- **性能红线**:长列表用 builder,不要用 children + for;1000 条数据一次性渲染会掉帧; +- 数据源接 Riverpod 后,`_items` 换成 `ref.watch(xxxProvider)`,刷新逻辑交给 provider(见 exam 系列); +- 分页的通用模式:`page` 页码 + `hasMore` 标记 + `_isLoadingMore` 防重入,三件套缺一不可; +- 想整页只滚一次(AppBar 折叠 + 列表混排)→ 升级到 [sliver_demo.md](sliver_demo.md) 的 CustomScrollView。 diff --git a/docs/demos/note_picker_demo.md b/docs/demos/note_picker_demo.md new file mode 100644 index 0000000..0f3ad94 --- /dev/null +++ b/docs/demos/note_picker_demo.md @@ -0,0 +1,140 @@ +# 【Demo】笔记图片选择页(多图选择 + 缩略图网格 + 删除角标) + +> 源文件:`lib/pages/demo/note_picker_demo.dart` | 分类:组件封装 +> 一句话:学生上传笔记前的"选图 + 预览 + 确认"页 —— 网格缩略图、右下角加号、底部提交按钮。 + +--- + +## 一、这个 Demo 解决什么问题 + +笔记上传前要先选图、预览、确认。本页是一个真实的**多图选择页**: + +``` +选图(PickerService 弹层)→ 网格缩略图预览 → 删除不满意的 → 底部提交(预留上传接口) +``` + +它演示了三个可复用的套路: +1. **加号格子**:GridView 比图片多一格,点它弹选图层; +2. **删除角标**:每张图右上角一个小圆叉(Stack + Positioned); +3. **双端缩略图**:同一份代码在原生 / Web 都能显示缩略图(`PickerService.buildThumb`)。 + +> 对照 Vue:相当于"el-upload 多图模式 + 预览删除"的前端部分,上传接口那步留了 TODO。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: NotePickerPage())); +``` + +- 点加号 → 弹「拍照/相册」底部弹层(原生是微信风格相册,可多选;Web 降级 image_picker); +- 选完图回到网格,右上角小叉可删; +- 选满 9 张后加号自动消失; +- 提交按钮显示 `上传笔记(n/9)`,未选图时点击提示"请先选择笔记图片"。 + +--- + +## 三、核心知识点(新手向) + +### 1. 网格多一格 = 加号位 +```dart +GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, // 3 列 + mainAxisSpacing: 10.h, + crossAxisSpacing: 10.w, + ), + itemCount: _images.length + 1, // 关键:多一格给"加号" + itemBuilder: (context, index) { + if (index == _images.length) { // 最后一格 + if (_images.length >= _maxCount) { + return const SizedBox.shrink(); // 满了:占位空格 + } + return _buildAddCell(); // 没满:加号 + } + return _buildImageCell(index); // 前面是已选图片 + }, +) +``` + +### 2. 删除角标 = Stack + Positioned +```dart +Stack( + fit: StackFit.expand, + children: [ + ClipRRect(borderRadius: BorderRadius.circular(10.r), child: thumb), + Positioned( + top: 4.h, right: 4.w, // 钉在右上角 + child: GestureDetector( + onTap: () => _onRemoveTap(index), + child: Container( // 20x20 黑色半透明圆 + width: 20.w, height: 20.h, + decoration: const BoxDecoration( + color: Colors.black54, shape: BoxShape.circle), + child: const Icon(Icons.close, size: 12, color: Colors.white), + ), + ), + ), + ], +) +``` + +### 3. 双端缩略图:永远走服务层,别自己判断平台 +```dart +Widget _buildThumb(PickedImage image) { + return PickerService.buildThumb(image); + // 原生 → AssetEntityImage(实体缩略图,秒开不卡顿) + // Web → Image.memory(字节) +} +``` + +### 4. 提交按钮的"禁用态"套路 +```dart +FilledButton( + onPressed: _uploading ? null : _onSubmit, // null = 禁用(置灰) + child: Text(_uploading ? '上传中...' : '上传笔记(${_images.length}/$_maxCount)'), +) +``` +`onPressed` 传 `null` 就是禁用,这是 Flutter 的通用约定(比 Vue 的 `:disabled` 更内建)。 + +--- + +## 四、关键代码剖析 + +```dart +Future _onAddTap() async { + final picked = await PickerService.instance.showPickSheet( + context, + maxCount: _maxCount - _images.length, // 剩余可选张数(限总数 9) + ); + if (picked == null || picked.isEmpty) return; // 用户取消 + setState(() => _images.addAll(picked)); // 不可变对象直接合并 +} +``` + +提交时预留了上传接口位(等接口文档到了替换 TODO): +```dart +// TODO: 上传接口待用户提供(UploadApi.uploadImages 已预留位置) +// final urls = await UploadApi.instance.uploadImages(_images); +// 成功后把 urls 提交给"创建笔记"接口,再 Navigator.pop 返回。 +await Future.delayed(const Duration(milliseconds: 500)); // 模拟请求 +``` + +--- + +## 五、踩坑记录 + +1. **千万别在 GridView 里用 `Image.file` 判断平台**——`dart:io` 在 Web 编译不过,必须用 `AssetEntityImage`(原生)/ `Image.memory`(Web); +2. `PickedImage` 是不可变对象:直接 `addAll` 进列表再 `setState` 即可,**不需要深拷贝**; +3. 上传前校验:没选图时点击要提示,别发空请求; +4. `setState` 里 `_images.removeAt(index)` 即可删图,网格随 `itemCount` 变化自动收缩; +5. 满 9 张时加号格用 `SizedBox.shrink()` 占位而不是直接不渲染,**保持网格布局稳定**。 + +--- + +## 六、扩展思考 + +- 选图弹层本身是另一个封装(`showPickSheet` 内部是 `showModalBottomSheet`),见 [smooth_sheet_demo.md](smooth_sheet_demo.md) 的"模态 vs 非模态"对比; +- 上传动作的通用包装(loading 防重入、finally 复位)见 [share_demo.md](share_demo.md) 的 `_sharing` 模式; +- 真实项目:把 TODO 处换成 `UploadApi.uploadImages`,再把返回的 url 列表交给"创建笔记"接口——本页结构不用动。 diff --git a/docs/demos/oss_upload_demo.md b/docs/demos/oss_upload_demo.md new file mode 100644 index 0000000..187f340 --- /dev/null +++ b/docs/demos/oss_upload_demo.md @@ -0,0 +1,121 @@ +# 【Demo】OSS 直传(客户端签名 + PUT 上传) + +> 源文件:`lib/pages/demo/oss_upload_demo.dart` | 分类:网络 +> 一句话:选图 → 后端分配路径 → 拿 STS → V4 签名 → PUT 直传 OSS,全程带进度。 + +--- + +## 一、这个 Demo 解决什么问题 + +图片上传不走"客户端 → 业务服务器 → OSS"的转发链路(浪费带宽、服务器压力大), +而是 **STS 临时凭证 + 客户端直传 OSS**:后端只负责分配路径和签发凭证,图片字节直接传阿里云。 + +### 完整链路(与 UniApp ossUpload.js 对照) +``` +选图 → 后端分配路径(GetXxxOSSUploadUrl) + → 拿 STS 凭证(GetAliOSSSetting) + → V4 签名 + → PUT 直传 OSS(带进度) + → 返回 CDN URL +``` + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: OssUploadDemoPage())); +``` + +1. 选择上传场景:头像 / 笔记 / AI判题(`UploadScene` 枚举,对应后端 mode); +2. "选择图片"(走 PickerService,见 [note_picker_demo.md](note_picker_demo.md)); +3. "上传到 OSS":读字节 → 4 步链路 → 显示 CDN URL + 进度。 + +> ⚠ 需要后端 OSS 接口可访问(本地/测试环境)。 + +--- + +## 三、核心知识点(新手向) + +### 1. 双端读取图片字节(不用 dart:io!) +```dart +// web :image_picker 已读好字节 +// 原生 :photo_manager 的 AssetEntity.originBytes(纯 Dart API) +final Uint8List bytes = image.bytes ?? + await image.entity?.originBytes ?? + Uint8List(0); +if (bytes.isEmpty) { /* 提示重新选择 */ } +``` +**关键:页面不 import `dart:io`,Web 才能编译**(dart:io 在 Web 是禁用的)。 +双端都能拿字节 → 上传逻辑统一。 + +### 2. 上传入口(OssUploader 封装) +```dart +final url = await OssUploader.uploadImage( + bytes: bytes, + fileName: fileName, + scene: _scene, // UploadScene 枚举:头像/笔记/AI判题 + onSendProgress: (sent, total) { + if (total > 0) { + setState(() => _progress = sent / total); // total<=0 兜底 + } + }, +); +``` + +### 3. 403 的含义 +**403 = 签名或 STS 有问题** —— 看控制台 LogInterceptor 打印的响应体定位: +- AccessDenied:签名计算不一致(method/path/时间戳对不上); +- InvalidAccessKeyId / ExpiredToken:STS 凭证失效; +- SignatureDoesNotMatch:V4 签名算法细节错。 + +--- + +## 四、关键代码剖析 + +```dart +// 场景名映射(switch 表达式,Dart 3) +static String _sceneName(UploadScene s) => switch (s) { + UploadScene.avatar => '头像', + UploadScene.note => '笔记', + UploadScene.aiGrading => 'AI判题', + }; + +// 场景选择(上传中禁用切换) +Wrap( + spacing: 10.w, + children: UploadScene.values + .map((s) => ChoiceChip( + label: Text(_sceneName(s)), + selected: _scene == s, + onSelected: _uploading ? null : (_) => setState(() => _scene = s), + )) + .toList(), +) + +// 上传按钮(没选图或上传中 → 禁用) +FilledButton.icon( + onPressed: (_image == null || _uploading) ? null : _upload, + icon: const Icon(Icons.cloud_upload_outlined), + label: Text(_uploading ? '上传中...' : '上传到 OSS'), +) +``` + +--- + +## 五、踩坑记录 + +1. **别用 `Image.file` 双端判断** —— dart:io 在 Web 编译不过,统一走 bytes; +2. **403 优先查签名**:method/URL 编码/时间格式与后端约定不一致是头号原因; +3. `total <= 0` 时进度要兜底(和 dio_pro_demo 同一个坑); +4. 上传中要禁用场景切换和重复点击(`_uploading` 统一控制); +5. 文件名生成:`image.name ?? 'image_时间戳.jpg'`,避免空名。 + +--- + +## 六、扩展思考 + +- **防盗链/私有读**:OSS 私有 Bucket 的 URL 需要签名,展示时再向后端换"临时公开 URL"; +- 断点续传/分片:大文件(视频)用 OSS 分片上传 API,`uploadFile` 封装在 OssUploader 里扩展; +- 与 [dio_pro_demo.md](dio_pro_demo.md) 对照:PUT 直传的进度就是 `onSendProgress` 的应用; +- 笔记图片多图上传:先全部直传拿 URL,再一次性提交给"创建笔记"接口(见 note_picker_demo 的 TODO 注释)。 diff --git a/docs/demos/picker_page.md b/docs/demos/picker_page.md new file mode 100644 index 0000000..69385af --- /dev/null +++ b/docs/demos/picker_page.md @@ -0,0 +1,107 @@ +# 【Demo】选择器页:pop 带值返回 + +> 源文件:`lib/pages/demo/picker_page.dart` | 分类:路由 +> 一句话:路由返回值的经典用法 —— 点"男/女",把结果带回上一个页面。 + +--- + +## 一、这个 Demo 解决什么问题 + +"选性别"这类**选择器页**:打开 → 点选项 → 关闭并把结果带回调用页。 +本页是 34 行的最小完整例子,专门讲清 **pop 的第二个参数**。 + +``` +首页 await push 选择器页 → 用户点"男" → Navigator.pop(context, '男') +→ 首页的 await 拿到 '男' → 弹 SnackBar 展示 +``` + +> 对照 Vue:等价于 `this.$emit('select', '男')` 后关闭弹窗——Flutter 里返回值走 `pop` 的第二个参数。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const RouterApp()); // 从首页"方式 2"按钮进入本页 +``` + +首页点"选择性别" → 本页两个选项(男/女)→ 点任一 → 回到首页并弹 SnackBar 显示结果; +按系统返回键(不选)→ 首页提示"未选择"。 + +--- + +## 三、核心知识点(新手向) + +### 1. pop 的第二个参数就是"返回值" +```dart +ListTile( + title: const Text('男'), + onTap: () => Navigator.pop(context, '男'), // 第二个参数:返回给上一页的值 +), +``` + +### 2. 与 push 侧配对使用 +```dart +// 接收侧(router_home_page.dart) +final result = await Navigator.push(context, MaterialPageRoute(...)); +// 返回侧(本页) +Navigator.pop(context, '男'); +``` +- `pop(context, value)` 的值类型与 `push` 的泛型对应; +- **用户没选(系统返回键 / 手势返回)→ 没调 pop 带值 → 接收侧拿到 `null`**,所以接收侧要 `?? '未选择'` 兜底。 + +### 3. pop 之后,页面就销毁了 +`Navigator.pop` 会把当前路由出栈并销毁。所以**pop 之后再不能用本页的 context**(页面没了)——这也是"await 之后判 mounted"问题的另一面。 + +--- + +## 四、关键代码剖析 + +```dart +class PickerPage extends StatelessWidget { + const PickerPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('选择性别')), + body: Column( + children: [ + ListTile( + leading: const Icon(Icons.male, color: Color(0xFF3B82F6)), + title: const Text('男'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.pop(context, '男'), // ← 带值返回 + ), + Divider(height: 1.h, indent: 16, endIndent: 16), + ListTile( + leading: const Icon(Icons.female, color: Color(0xFFF06292)), + title: const Text('女'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.pop(context, '女'), + ), + ], + ), + ); + } +} +``` + +全程没有 `setState`、没有返回值声明——**StatelessWidget 就够了**,选择器页是最典型的"纯跳转 + 带值返回"场景。 + +--- + +## 五、踩坑记录 + +1. **pop 不带值 = 返回 null**:用系统返回键关页时不会触发任何 `pop(context, value)`,接收侧必须处理 null; +2. **类型要配对**:`push` 对应 `pop(context, '男')`;如果你 push 没写泛型,pop 的值类型就是 dynamic,接收侧要强转; +3. pop 之后本页 context 已失效,别再 await 什么(本页没有异步,天然安全); +4. 更多返回值的真实场景:日期选择、城市选择、筛选条件——结构都和本页一致。 + +--- + +## 六、扩展思考 + +- 接收侧完整代码见 [router_home_page.md](router_home_page.md)(`await push` + `context.mounted` + `?? '未选择'`); +- 参数**传入**的两种通道见 [detail_page.md](detail_page.md)(构造参数 vs 命名路由 arguments); +- 进阶:`Navigator.pop` 可以返回任意类型(对象、Map),比如"选了一个城市"返回整个城市模型,接收侧依然按泛型拿。 diff --git a/docs/demos/responsive_demo.md b/docs/demos/responsive_demo.md new file mode 100644 index 0000000..269a4e5 --- /dev/null +++ b/docs/demos/responsive_demo.md @@ -0,0 +1,118 @@ +# 【Demo】响应式布局:一套代码适配手机 / Pad + +> 源文件:`lib/pages/demo/responsive_demo.dart` | 分类:基础布局 +> 一句话:屏幕适配 ≠ 响应式布局 —— 一个是"等比缩放",一个是"改变结构"。 + +--- + +## 一、这个 Demo 解决什么问题 + +同一个页面,手机单列列表、Pad 双列卡片墙,**一套代码自动切换**。 +新手最容易混淆的概念在这里讲清楚。 + +### 两个维度(务必分清) +| 概念 | 手段 | 解决什么 | +|---|---|---| +| **屏幕适配** | flutter_screenutil(`.w/.h/.sp`) | 不同屏幕比例不变形(尺寸等比缩放) | +| **响应式布局** | 断点 + LayoutBuilder(本 Demo) | 不同屏幕宽度改变布局**结构**(单列/双列) | + +> 两者配合使用:尺寸用 `.w/.h/.sp` 缩放,结构用断点切换。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: ResponsiveDemoPage())); +``` + +- 顶部模式条实时显示"手机模式 / Pad 模式 + 当前宽度 dp"; +- 手机(<600dp)→ 单列 ListTile 列表; +- Pad(≥600dp)→ 多列卡片网格(列数按宽度自适应)。 + +--- + +## 三、核心知识点(新手向) + +### 1. 断点标准(Material 官方) +| 宽度 | 设备 | +|---|---| +| < 600dp | 手机 | +| 600 ~ 839dp | 小平板 / 折叠屏 | +| >= 840dp | 大平板 / 桌面 | + +> ⚠ 关键:判断的是**屏幕宽度**,不是 Platform! +> iOS 也有 iPad,Android 也有平板 —— 用平台判断永远分不清它们。 + +### 2. 两种拿宽度的方式 +```dart +MediaQuery.sizeOf(context).width // 整个屏幕宽度 +LayoutBuilder(...) // 父容器实际给你的可用宽度 +``` +- 页面铺满屏幕时两者一样; +- 页面内有**可伸缩区域**(侧栏收起/展开)时必须用 `LayoutBuilder`; +- 本 Demo 用 `LayoutBuilder`,因为能拿到最真实的可用宽度。 + +### 3. 核心切换逻辑 +```dart +LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final isTablet = width >= _tabletBreakpoint; // 600dp + // 列数自适应:每列至少 280dp 宽,最多 4 列 + final columns = (width / _minCardWidth).floor().clamp(1, 4); + + return isTablet + ? _buildGrid(columns) // Pad:多列网格 + : _buildList(); // 手机:单列列表 + }, +) +``` + +--- + +## 四、关键代码剖析 + +```dart +// 手机列表项 +ListView.separated( + padding: EdgeInsets.all(12.w), + itemCount: _items.length, + separatorBuilder: (_, _) => SizedBox(height: 8.h), + itemBuilder: (context, i) => _ListTileItem(item: _items[i]), +) + +// Pad 卡片网格(同一个数据源 _items,只是换了一种展示容器) +GridView.builder( + padding: EdgeInsets.all(12.w), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, // 由宽度算出来的列数 + mainAxisSpacing: 12.w, + crossAxisSpacing: 12.w, + childAspectRatio: 1.4, // 宽高比,数字大了卡片变矮 + ), + itemCount: _items.length, + itemBuilder: (context, i) => _CardItem(item: _items[i]), +) +``` + +**关键思想:数据不变,变的是"容器"** —— 同一份 `_items`,手机渲染成列表项,Pad 渲染成卡片。 +组件只负责"怎么显示一条数据",页面负责"按宽度选容器"。 + +--- + +## 五、踩坑记录 + +1. **别用 `Platform.isAndroid` 判断手机/Pad** —— 永远用宽度断点; +2. `MediaQuery.sizeOf(context).width` 拿的是全屏宽,页面嵌在可伸缩容器里会算错 —— 用 LayoutBuilder; +3. `(width / 280).floor()` 在窄屏可能算出 0 列 —— 必须 `.clamp(1, 4)` 兜底; +4. 断点值用常量集中管理(`_tabletBreakpoint = 600`),别散落在各页面写死魔法数字。 + +--- + +## 六、扩展思考 + +- **官方推荐**:`flutter/foundation` 的 `defaultTargetPlatform` + 宽度断点结合;或引入 `responsive_builder` 包; +- 平板横竖屏切换:竖屏 8.4" ≈ 800dp,横屏 1280dp —— 断点要覆盖两种情况; +- 进阶模式:`LayoutBuilder` + `CustomScrollView`([sliver_demo.md](sliver_demo.md))组合做"响应式长列表"; +- 本项目 theme_demo 的 `PageTheme` 按页面层级给配色,响应式按宽度给结构 —— 两个维度正交,可自由组合。 diff --git a/docs/demos/rich_text_demo.md b/docs/demos/rich_text_demo.md new file mode 100644 index 0000000..81562ea --- /dev/null +++ b/docs/demos/rich_text_demo.md @@ -0,0 +1,123 @@ +# 【Demo】富文本渲染(RichTextContent:HTML → 题目/解析) + +> 源文件:`lib/pages/demo/rich_text_demo.dart` | 分类:组件封装 +> 一句话:后端给的 HTML(题目/解析/听力材料)渲染成富文本 —— 还支持字号调节、深色模式、自定义样式。 + +--- + +## 一、这个 Demo 解决什么问题 + +教育 App 的题目/解析是**后端返回的 HTML 字符串**(``、``、``、``…), +前端要把它渲染成漂亮的可交互富文本。本页演示 `RichTextContent`(`lib/core/components/rich_text_content.dart`): + +- 完整渲染:标题、粗体、**上标下标**、`` 选项、`` 表格; +- `` 标签**自动替换成本项目的 AudioPlayerCard**; +- 学生端能力:**字号 Aa± 实时调节、深色模式切换**(通过 `textStyle` 注入); +- 自定义样式:`.answer` 类 → 红色加粗(`customStylesBuilder`)。 + +> 对照 Vue:相当于 `v-html` + 样式定制,但 Flutter 没有浏览器引擎,得用 flutter_html 这类包逐标签渲染。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: RichTextDemoPage())); +``` + +- 顶部控制栏:滑块拖字号(12~24)、切深色开关 → 正文实时变化; +- 页面里:化学方程式上下标、本地图片(`asset://` 协议)、表格、听力音频、视频、红色加粗的答案 B。 + +--- + +## 三、核心知识点(新手向) + +### 1. 一行渲染 HTML +```dart +RichTextContent( + html: _html, // 后端返回的 HTML 字符串 + // 字号 + 深色模式都通过 textStyle 注入(学生端统一入口) + textStyle: TextStyle( + fontSize: _fontSize, + height: 1.6, + color: _dark ? Colors.white70 : const Color(0xFF333333), + ), +) +``` +**`textStyle` 注入是核心设计**:页面不重建 HTML、不改渲染器,只改一个 TextStyle 就全局换字号/颜色——学生端"无障碍调节"的标准做法。 + +### 2. 自定义样式:customStylesBuilder +```dart +customStylesBuilder: (element) { + if (element.className.contains('answer')) { + return const {'color': '#EF4444', 'font-weight': 'bold'}; + } + return null; // 其他元素不特殊处理 +}, +``` +后端 HTML 里写 `B` → 渲染成红色加粗。**按 class 定制样式**,不用后端改结构。 + +### 3. 本地图片:asset:// 协议 +```html + +``` +`asset://` + 资源路径 = 加载本地资源图片(最稳,不受网络影响)。实际后端返回完整 URL 或相对路径(相对路径需业务层拼 baseUrl)。 + +### 4. audio 标签自动替换 +```html + +``` +组件内部检测 `` → 替换成 `AudioPlayerCard`(见 [audio_player_demo.md](audio_player_demo.md))——听力题直接能播。 + +--- + +## 四、关键代码剖析 + +```dart +static const String _html = ''' +第 1 题 · 阅读理解 +光合作用是植物利用光能将二氧化碳和水合成有机物并释放氧气的过程。化学方程式:6CO2 + 6H2O → C6H12O6 + 6O2,面积用 x2 表示。 + + + A. 光合作用只发生在夜间 + B. 光合作用会释放氧气 + + + 条件有光黑暗 + 释放 O₂√× + + +【解析】正确答案:B。 +'''; +``` + +字号控制条(12~24 共 12 档): +```dart +Slider( + value: _fontSize, + min: 12, + max: 24, + divisions: 12, // 档位数 → 滑块只能停在整数 + activeColor: const Color(0xFF3B82F6), + onChanged: (v) => setState(() => _fontSize = v), +) +``` + +--- + +## 五、踩坑记录 + +1. **textStyle 只影响文本**:图片、表格、AudioPlayerCard 是各自样式——深色模式下卡片白底不随主题变(业务侧需按需适配); +2. **内联 CSS 只支持常用属性**,复杂布局会降级(flutter_html 的边界,别拿它当浏览器); +3. 公式(LaTeX)本 demo 未含:以后在 `RichTextContent` 的 `customWidgetBuilder` 里扩展(如 flutter_math_fork); +4. 相对路径图片要业务层拼 baseUrl,别把裸相对路径直接塞进 html; +5. 深色模式只是 `_dark` 布尔 + 换色,**页面底色/AppBar 也要跟着换**(本页手动处理了,主题化见 [theme_demo.md](theme_demo.md))。 + +--- + +## 六、扩展思考 + +- `` 替换成的播放器组件见 [audio_player_demo.md](audio_player_demo.md); +- 富文本在真实考试页的运用见 [exam_demo.md](exam_demo.md)(题干 HTML 渲染); +- 学生端"字号调节 + 深色模式"这类**偏好设置**,进阶做法是存进全局状态(Riverpod),见 [exam_architecture.md](../exam_architecture.md) 的 Riverpod 章节; +- 真实项目:`_html` 换成接口返回的题目 HTML,`asset://` 换成 CDN 完整 URL 即可。 diff --git a/docs/demos/router_demo.md b/docs/demos/router_demo.md new file mode 100644 index 0000000..2ee8080 --- /dev/null +++ b/docs/demos/router_demo.md @@ -0,0 +1,99 @@ +# 【Demo】路由导航:应用入口(RouterApp) + +> 源文件:`lib/pages/demo/router_demo.dart` | 分类:路由 +> 一句话:一个只负责"组装"的入口文件 —— 注册路由表、指定首页。 + +--- + +## 一、这个 Demo 解决什么问题 + +把路由导航 Demo 的三个页面(首页 / 详情页 / 选择器页)组装成一个可运行的应用。 +演示的是**组件化结构 + 命名路由表**: + +``` +lib/pages/demo/router_demo.dart → 应用入口(只组装,不写页面) +lib/pages/demo/router_home_page.dart → 首页(三个跳转按钮) +lib/pages/demo/detail_page.dart → 详情页(接收参数) +lib/pages/demo/picker_page.dart → 选择器页(pop 带值返回) +``` + +> 对照 Vue:RouterApp ≈ `main.ts + router/index.ts`;每个页面一个文件 ≈ `views/` 目录。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const RouterApp()); +``` + +首页三个按钮,演示三种跳转姿势: +1. push + 构造函数传参; +2. push 选择器并 **await 接收 pop 返回的结果**; +3. pushNamed 命名路由 + arguments 传参。 + +--- + +## 三、核心知识点(新手向) + +### 1. 命名路由表 +```dart +MaterialApp( + title: '路由导航 Demo', + theme: ThemeData(colorSchemeSeed: const Color(0xFF3B82F6)), + // 命名路由表:name -> 页面构造(详情页在这里注册) + routes: { + '/detail': (context) => const DetailPage(), + }, + home: const RouterHomePage(), +) +``` +- 路由名规范:`/xxx` 开头,如 `/detail`、`/user/info`; +- 注册后才能用 `Navigator.pushNamed(context, '/detail')`; +- `home` 是应用启动后的第一个页面(相当于 Vue 的默认路由)。 + +### 2. 命名规范 +| 内容 | 规范 | +|---|---| +| 文件名 | snake_case(小写下划线):`router_home_page.dart` | +| 类名 | PascalCase:`RouterHomePage` | +| 引入 | 跨文件用 `import` | +| 文件组织 | **每个公开类一个文件** | + +--- + +## 四、关键代码剖析 + +```dart +class RouterApp extends StatelessWidget { + const RouterApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: '路由导航 Demo', + theme: ThemeData(colorSchemeSeed: const Color(0xFF3B82F6)), + routes: { + '/detail': (context) => const DetailPage(), + }, + home: const RouterHomePage(), + ); + } +} +``` + +--- + +## 五、踩坑记录 + +1. **入口文件只负责组装**,别在里面堆业务代码——页面跳转见 [router_home_page.md](router_home_page.md); +2. 命名路由的 `arguments` 是 dynamic,详情页取值要判空强转(见 [detail_page.md](detail_page.md)); +3. 真实项目路由更复杂(嵌套/守卫/转场),用 go_router 或 auto_route 包,本项目 demo 用原生 Navigator 讲清原理。 + +--- + +## 六、扩展思考 + +- 三种跳转姿势的详细讲解见 [router_home_page.md](router_home_page.md); +- 参数接收的两种方式见 [detail_page.md](detail_page.md); +- 真实项目里 MaterialApp 在 `lib/main.dart`,路由表在 `core/router/` 或 go_router 的配置里——原理与本页一致。 diff --git a/docs/demos/router_home_page.md b/docs/demos/router_home_page.md new file mode 100644 index 0000000..67a94fa --- /dev/null +++ b/docs/demos/router_home_page.md @@ -0,0 +1,104 @@ +# 【Demo】路由首页:三种跳转姿势 + +> 源文件:`lib/pages/demo/router_home_page.dart` | 分类:路由 +> 一句话:push 传参 / await 接返回值 / pushNamed 命名路由 —— 跳转的三个基本姿势。 + +--- + +## 一、这个 Demo 解决什么问题 + +页面跳转是 App 的基本操作。本页演示三种姿势及其适用场景: +1. **push + 构造函数传参**:参数少、类型安全,最常用; +2. **push 并等待返回结果**:从"选择器页"带值回来(选性别等); +3. **pushNamed 命名路由 + arguments**:按路由名跳转,参数以 Map 传递。 + +> 核心思想:**页面跳转是"入栈/出栈"**(Navigator 是个栈)。 +> push = 压栈,pop = 出栈;pop 时带值 → push 的 await 拿到值。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const RouterApp()); +``` + +- 方式 1:跳详情页(张三,18); +- 方式 2:跳选择器页,选"男/女" → 返回首页弹 SnackBar 显示选择结果; +- 方式 3:跳详情页(李四,25,走命名路由)。 + +--- + +## 三、核心知识点(新手向) + +### 1. 方式 1:push + 构造函数传参(类型安全,首选) +```dart +Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const DetailPage(name: '张三', age: 18), + ), +); +``` +- 参数是 Dart 强类型:写错类型编译期就报错; +- 页面构造写在 `builder` 里(路由是"惰性构建",进入页面才执行)。 + +### 2. 方式 2:await push 接收 pop 返回值 +```dart +onPressed: () async { + // 注意:push 返回的是 Future,用 await 接收 pop 带回来的值 + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const PickerPage()), + ); + if (context.mounted) { // await 之后必须判 mounted! + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('你选择了:${result ?? '未选择'}')), + ); + } +}, +``` +- 泛型 `` 声明返回类型; +- 用户点系统返回键(没有 pop 带值)→ result 为 null,`?? '未选择'` 兜底; +- **await 后 context.mounted 必须判**(页面可能已被销毁)。 + +### 3. 方式 3:pushNamed 命名路由 + arguments +```dart +Navigator.pushNamed( + context, + '/detail', // 必须在 MaterialApp.routes 里注册过 + arguments: {'name': '李四', 'age': 25}, +); +``` +- arguments 是 `dynamic`(任意类型),取值需判空强转(见 [detail_page.md](detail_page.md)); +- 适合路由名集中管理的场景,代价是**丢失了类型安全**。 + +--- + +## 四、关键代码剖析 + +```dart +// 页面底部常驻的知识点提示(学习型 Demo 的惯例) +Text( + '知识点:页面跳转是【入栈/出栈】。' + '返回页 pop 时带值 → 接收页 await push 拿到值。' + '命名路由的 arguments 是 dynamic,取值需判空强转。', +) +``` + +--- + +## 五、踩坑记录 + +1. **`await` 之后用 `context` 必须先判 `context.mounted`**(Flutter 3.7+ 的推荐写法,替代旧式 `if (!mounted) return`); +2. `pushNamed` 没在 routes 注册 → 运行期 `onUnknownRoute` 报错/黑屏; +3. 忘了 `await push` 就收不到 pop 返回值; +4. 返回结果用泛型约束:`Navigator.push`,别让类型随意流转。 + +--- + +## 六、扩展思考 + +- pop 带值的一方见 [picker_page.md](picker_page.md)(`Navigator.pop(context, '男')`); +- 参数接收的两种通道见 [detail_page.md](detail_page.md); +- 真实项目推荐 go_router:声明式路由 + 守卫 + 深链,但底层仍是 Navigator 这套入栈/出栈机制。 diff --git a/docs/demos/seed_palette_demo.md b/docs/demos/seed_palette_demo.md new file mode 100644 index 0000000..7a1d0f9 --- /dev/null +++ b/docs/demos/seed_palette_demo.md @@ -0,0 +1,91 @@ +# 【Demo】Seed 色板验证页(colorSchemeSeed 原理) + +> 源文件:`lib/pages/demo/seed_palette_demo.dart` | 分类:基础布局 +> 一句话:所有"没写死颜色"的 Material 组件,换一个 seed 就整体变色 —— 本页就是证据。 + +--- + +## 一、这个 Demo 解决什么问题 + +新手经常困惑:`colorSchemeSeed` 到底有什么用?为什么有的页面换主题变色,有的不变? + +答案:**MaterialApp 的 `theme.colorSchemeSeed` 会自动生成整套 ColorScheme**, +页面里**不指定颜色**的 Material 组件(FilledButton/Checkbox/Switch/进度圈/聚焦输入框等) +颜色全部取自这套色板。本页把所有"不写死颜色"的组件摆在一起,换 seed 看整体变色。 + +--- + +## 二、怎么运行 / 演示内容 + +```bash +flutter run # 默认品牌 → 本页所有组件 SkyBlue 蓝 +flutter run --dart-define=BRAND=chucai # 换品牌 → 本页所有组件变 Red 红 +``` + +`--dart-define` 是编译期变量,由 `core/config/app_config.dart` 读取并决定 `seedColor`。 + +> 对照登录页为什么不变色:登录页所有颜色写死 `Color(0xFF3B82F6)` → 永远蓝色,不随主题走。 +> **写死了就不跟主题,没写死才跟** —— 这是理解主题系统的关键。 + +--- + +## 三、核心知识点(新手向) + +### 1. 色板怎么生成的 +```dart +ThemeData(colorSchemeSeed: seedColor) +``` +Material 的 ColorScheme.fromSeed 算法从种子色推出一整套颜色: +primary / onPrimary / secondary / tertiary / error / surface 等几十个语义色。 +组件默认取语义色:按钮取 primary、进度圈取 primary、Checkbox 选中取 primary…… + +### 2. 本页验证清单(全部未写死颜色) +- 按钮组:FilledButton / OutlinedButton / TextButton; +- Checkbox / Switch / ChoiceChip 选中态; +- CircularProgressIndicator / LinearProgressIndicator; +- TextField 聚焦边框 + 光标。 + +### 3. 取色值显示 +```dart +String _hex(Color c) { + final v = c.toARGB32() & 0xFFFFFF; // Flutter 3.27+ 用 toARGB32 取色值 + return '#${v.toRadixString(16).padLeft(6, '0').toUpperCase()}'; +} +``` + +--- + +## 四、关键代码剖析 + +```dart +// 当前品牌主题(来自 AppConfig,启动时由 --dart-define 决定) +final brandTheme = AppConfig.theme; + +// 展示 seed 色块 + 品牌信息 +Container( + width: 56.w, height: 56.h, + decoration: BoxDecoration(color: brandTheme.seedColor, ...), +), +Text('主题 ${brandTheme.name} · seed ${_hex(brandTheme.seedColor)}'), + +// 一个不写颜色的组件(示例)—— 它的颜色来自当前 seed 色板 +FilledButton(onPressed: () {}, child: const Text('FilledButton')), +``` + +--- + +## 五、踩坑记录 + +1. **`.w/.h` 的 widget 不能 const** —— 本页 CircularProgressIndicator 外面套的 SizedBox 就踩过 + (`const_eval_extension_method` 编译错误); +2. `toARGB32()` 是新 API,旧的 `.value` 已废弃; +3. 别在页面里 `Theme.of(context).colorScheme.primary` 到处取 —— 组件不写颜色才是正道; +4. `--dart-define` 是编译期常量,热重载不生效,改品牌要重新 run。 + +--- + +## 六、扩展思考 + +- 二级主题(页面级细节)见 [theme_demo.md](theme_demo.md); +- 想精确控制色板:`ColorScheme.fromSeed(seedColor: x, primary: y, ...)` 手动覆盖某些槽位; +- 动态切换主题(运行时换色):Theme 局部覆盖或 AnimatedTheme,不依赖 --dart-define。 diff --git a/docs/demos/share_demo.md b/docs/demos/share_demo.md new file mode 100644 index 0000000..aedb0b8 --- /dev/null +++ b/docs/demos/share_demo.md @@ -0,0 +1,150 @@ +# 【Demo】分享功能演示(ShareService 五个场景) + +> 源文件:`lib/pages/demo/share_demo.dart` | 分类:组件封装 +> 一句话:文本分享 / 选图分享 / Widget 截图生成海报分享 / 截图预览 / 保存相册 —— 一个服务类覆盖全部分享场景。 + +--- + +## 一、这个 Demo 解决什么问题 + +"分享"是 App 的标配能力,但场景很多:分享文字、分享图片、**把 UI 截图成海报分享**、保存到相册。 +本页演示 `ShareService`(`lib/core/services/share_service.dart`)的 5 个场景: + +| # | 场景 | 方法 | 用途 | +|---|---|---|---| +| 1 | 分享文本/链接 | `shareText(text:)` | 邀请链接、文案 | +| 2 | 选图后分享 | `shareImage(filePath:, text:)` | 分享单张图片 | +| 3 | Widget 截图 → 分享 | `shareFromWidget(context:, text:, builder:)` | **攻克海报分享** | +| 4 | Widget 截图预览 | `captureWidget(context:, builder:)` | 截图不发,先看效果 | +| 5a | 选图保存相册 | `saveToGallery(filePath:)` | 存图 | +| 5b | 截图保存相册 | `saveToGalleryFromBytes(bytes:)` | 海报存图 | + +> 核心思想:**"分享"是平台能力,不是页面能力** —— 封装成 Service 单例,任何页面一行调用。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: ShareDemoPage())); +``` + +- ① 输入框改文案 → 分享文本; +- ② 选一张图 → 系统分享面板; +- ③ 点"截图并分享" → 海报 Widget 被截成图片 → 分享面板; +- ④ 点"仅截图预览" → 弹窗显示截图 + 图片大小(KB); +- ④ 场景的两个按钮:`选图→保存` / `截图→保存` 写入系统相册。 + +--- + +## 三、核心知识点(新手向) + +### 1. 场景 3 的核心:Widget → 图片 → 分享 +```dart +final result = await ShareService.instance.shareFromWidget( + context: context, + text: '我攻克了一道难题,快来挑战!', + builder: (_) => _buildPosterWidget( // 任意 Widget 都能截图 + userName: '张三同学', + taskName: '二次函数综合练习', + score: 98, + ), +); +``` +海报就是一个**普通 Container**(300 宽、渐变背景、纯 UI 无交互)——不是图片素材,是代码画出来的。 +分享时被 `captureWidget` 截成 PNG 字节,再交给系统分享。 + +### 2. 场景 4:截图预览 = bytes → Image.memory +```dart +final bytes = await ShareService.captureWidget(context: context, builder: ...); +if (bytes == null) { _showResult('截图失败'); return; } +showDialog( + context: context, + builder: (ctx) => Dialog( + child: Column(children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.memory(bytes, width: 280), // 字节流直接渲染 + ), + Text('图片大小:${(bytes.length / 1024).toStringAsFixed(1)} KB'), + ]), + ), +); +``` +`captureWidget` 返回 `Uint8List?`——**null 表示截图失败**,必须判空。 + +### 3. 防重入 + 复位:异步按钮的三段式 +```dart +setState(() => _sharing = true); // ① 锁 +try { + ...await 分享... +} catch (e) { + _showResult('分享失败:$e'); +} finally { + if (mounted) setState(() => _sharing = false); // ③ 无论成败都解锁 +} +// 按钮:_shareButton(onPressed: _sharing ? null : onPressed) ② 置灰 +``` +`_sharing` 置灰所有分享按钮,防连点重复弹系统面板;`finally` 保证异常也不卡死按钮。 + +### 4. Web 端限制:路径分享不可用 +```dart +final filePath = picked.first.path; +if (filePath == null) { + _showResult('Web 端暂不支持文件路径分享'); // Web 拿不到真实文件路径 + return; +} +``` + +--- + +## 四、关键代码剖析 + +```dart +/// 模拟"攻克海报"布局(实际项目中从业务数据构建) +Widget _buildPosterWidget({ + required String userName, + required String taskName, + required int score, +}) { + return Container( + width: 300, + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + gradient: LinearGradient( // 海报背景:紫蓝渐变 + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF667EEA), Color(0xFF764BA2)], + ), + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.school, color: Colors.white, size: 40), // Logo + const Text('爱学蝶变', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + ... + Text('$score', style: const TextStyle(color: Colors.white, fontSize: 48, fontWeight: FontWeight.w800, height: 1)), + ]), + ); +} +``` +海报 Widget 的纪律:**固定宽度 + 纯展示 + 不依赖页面状态**(参数全传入)——这样截图永远稳定。 + +--- + +## 五、踩坑记录 + +1. **captureWidget 必须在页面挂载后调用**(context 有效),否则截图 null; +2. **bytes 判空**:`if (bytes == null) return;`,别直接 `Image.memory(bytes!)` 崩掉; +3. Web 端 `picked.first.path` 为 null——图片分享/存相册要走别的通道,代码里已降级提示; +4. 所有按钮共享 `_sharing` 一把锁:分享中所有分享按钮都禁用,防止并发弹面板; +5. `finally` 里必须判 `mounted` 再 setState(await 期间页面可能已销毁); +6. 海报是"代码画的图",字体/图标依赖系统字体,跨设备效果略有差异——正式海报可换用真实图片素材。 + +--- + +## 六、扩展思考 + +- 选图用的 `PickerService.showPickSheet` 与缩略图渲染见 [note_picker_demo.md](note_picker_demo.md); +- 异步三段式(锁 + try/catch + finally 复位)同样用在 [note_picker_demo.md](note_picker_demo.md) 的上传和 [dio_demo.md](dio_demo.md) 的请求里; +- 真实项目:"攻克海报"数据来自答题结果接口,把 `_buildPosterWidget` 的参数换成接口返回的 userName/taskName/score 即可; +- `ShareService` 内部用 share_plus / screenshot / gal 插件——想学封装思想可以直接读它的源码。 diff --git a/docs/demos/sliver_demo.md b/docs/demos/sliver_demo.md new file mode 100644 index 0000000..1dba624 --- /dev/null +++ b/docs/demos/sliver_demo.md @@ -0,0 +1,114 @@ +# 【Demo】CustomScrollView + Sliver 混排布局 + +> 源文件:`lib/pages/demo/sliver_demo.dart` | 分类:基础布局 +> 一句话:折叠 AppBar + 网格 + 长列表"整页只滚一次",靠 Sliver 碎片拼装。 + +--- + +## 一、这个 Demo 解决什么问题 + +页面要同时包含"可折叠的大图 AppBar + 网格区块 + 长列表区块"时: +- **ListView 不能嵌套 ListView/GridView**(同向滚动会冲突,控制权打架); +- 正解是 `CustomScrollView`:把所有区块变成 Sliver"碎片"拼在一起,**共用一个滚动控制器,整页只滚一次**。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: SliverDemoPage())); +``` + +页面从上到下:折叠大图 AppBar → 网格标题 → 12 格网格 → 列表标题 → 50 条列表, +下滑时 AppBar 收起固定,其余内容连续滚动。 + +--- + +## 三、核心知识点(新手向) + +### 1. Sliver 是什么 +Sliver = "碎片"。`CustomScrollView` 把多个 sliver 拼成一个统一滚动视图。 + +### 2. 常用 sliver 对照表 +| Sliver | 作用 | 对应普通组件 | +|---|---|---| +| `SliverAppBar` | 可折叠/固定的 AppBar | AppBar | +| `SliverToBoxAdapter` | 把普通组件(文字/卡片)塞进滚动流 | 任意 Widget | +| `SliverGrid.builder` | 网格区块(懒加载) | GridView | +| `SliverList.builder` | 列表区块(懒加载) | ListView.builder | +| `SliverPadding` | 给某个 sliver 加内边距 | Padding | + +### 3. 核心结构 +```dart +CustomScrollView( + slivers: [ + SliverAppBar( // ① 折叠 AppBar + expandedHeight: 180.h, + pinned: true, // 滚动时固定在顶部 + flexibleSpace: FlexibleSpaceBar( + title: Text('Sliver 系列 Demo'), + background: DecoratedBox(...), // 展开时的大图 + ), + ), + SliverToBoxAdapter( // ② 普通文字标题 + child: Padding(...Text('网格区块(SliverGrid)')), + ), + SliverPadding( // ③ 网格区块(带外边距) + padding: EdgeInsets.all(16.w), + sliver: SliverGrid.builder(...), + ), + SliverList.builder( // ④ 列表区块 + itemCount: 50, + itemBuilder: ..., + ), + ], +) +``` + +### 4. 判断口诀(什么时候用 Sliver) +- 页面是"**一个网格**"或"**一个列表**" → `GridView` / `ListView` 更简单; +- 页面是"**折叠头 + 网格 + 长列表**"混排 → `CustomScrollView + slivers`。 + +--- + +## 四、关键代码剖析 + +### 折叠 AppBar(本页的灵魂) +```dart +SliverAppBar( + expandedHeight: 180.h, // 展开时高度(大图区域) + pinned: true, // 上滑时缩成一条标题栏并固定在顶部 + backgroundColor: Color(0xFF3B82F6), + flexibleSpace: FlexibleSpaceBar( + title: Text('Sliver 系列 Demo'), + centerTitle: true, + background: DecoratedBox( // 展开时显示的大图(这里是渐变) + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF3B82F6), Color(0xFF8E6FE8)], + ), + ), + ), + ), +) +``` + +--- + +## 五、踩坑记录 + +1. **slivers 里的组件必须是 Sliver 家族**,普通 Widget(如 Container)直接放会报类型错误—— + 要用 `SliverToBoxAdapter` 包一层; +2. **同向嵌套滚动是禁区**:CustomScrollView 里不能再放 ListView/GridView; +3. `SliverAppBar` 不设 `pinned` 时,上滑后 AppBar 会完全滚出屏幕(floating 可以浮回)——按产品需求选; +4. Sliver 的懒加载与 GridView/ListView 相同:builder 系列才懒加载。 + +--- + +## 六、扩展思考 + +- **Sliver 与 GridView 的本质关系**(重点记忆): + `GridView = CustomScrollView + 1 个 SliverGrid`,两者都是"单个滚动 + 懒加载",区别只在混排能力; +- 进阶 Sliver:`SliverPersistentHeader`(吸顶 Tab)、`SliverAnimatedList`(增删动画列表)、`SliverAppBar` 的 `snap` 模式; +- 电商首页 = Sliver 混排的教科书场景:搜索栏吸顶 + 轮播 + 宫格 + 瀑布流商品; +- 对照 [responsive_demo.md](responsive_demo.md):Sliver 解决"滚动结构",响应式解决"不同宽度下的结构",两者可叠加。 diff --git a/docs/demos/smooth_sheet_demo.md b/docs/demos/smooth_sheet_demo.md new file mode 100644 index 0000000..5b943e6 --- /dev/null +++ b/docs/demos/smooth_sheet_demo.md @@ -0,0 +1,125 @@ +# 【Demo】常驻多停靠底部面板(PersistentSnapSheet) + +> 源文件:`lib/pages/demo/smooth_sheet_demo.dart` | 分类:组件封装 +> 一句话:模拟"附近学校"地图页 —— 背景可交互 + 底部常驻拖拽面板,3 个停靠点自动吸附。 + +--- + +## 一、这个 Demo 解决什么问题 + +地图类页面常见的交互:**底部常驻一个可拖拽面板**,像抽屉一样有多个停靠位置。 +本页模拟教育 App 的"附近学校"页面: + +| 停靠点 | 比例 | 显示内容 | +|---|---|---| +| 收起 | 12% 屏高 | 只有手柄 + "附近学校"标题 | +| 半屏 | 50% 屏高 | 前 2 所学校 + "查看全部" | +| 展开 | 85% 屏高 | 完整学校列表,内部 ListView 可滚动 | + +关键交互:面板**自由拖拽、松手自动吸附**最近停靠点;**背景(地图)始终可交互**(非模态);面板内列表滚到顶继续下拉可收起面板。 + +> 核心组件:`PersistentSnapSheet`(`lib/core/components/persistent_snap_sheet.dart`),是对 smooth_sheets 包的封装。 +> 对照 Vue:`showBottomSheet` / 抽屉组件的"常驻版"——不挡背景、可多档位。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: SmoothSheetDemoPage())); +``` + +- 拖拽面板松手 → 观察自动吸附最近停靠点; +- 收起状态点地图背景 → 背景仍可交互(非模态); +- 展开面板后滚动列表到底再下拉 → 面板收起; +- 顶部 3 个按钮可代码控制面板跳到指定停靠点。 + +--- + +## 三、核心知识点(新手向) + +### 1. 三行配置:停靠点比例 +```dart +/// 3 个停靠点:底部收起 / 半屏 / 大半屏 +static const List _snapFactors = [0.12, 0.5, 0.85]; +``` +`snapFactors` 是**占屏高的比例**(0.12 = 12% 屏高),组件负责换算成像素并做吸附动画。 + +### 2. 结构:Stack 背景层 + 面板层 +```dart +Scaffold( + body: Stack( + children: [ + _buildMapBackground(context), // 背景层:模拟地图(真实项目换地图 SDK) + PersistentSnapSheet( // 面板层:常驻面板 + snapFactors: _snapFactors, + child: Column( + children: [ + _buildHandle(context), // 手柄区域 + Expanded(child: _buildSheetContent(context)), // 面板内容 + ], + ), + ), + ], + ), +) +``` + +### 3. 经典拖拽指示条(36×4 圆角小横条) +```dart +Container( + width: 36.w, + height: 4.h, + decoration: BoxDecoration( + color: const Color(0xFFD1D5DB), + borderRadius: BorderRadius.circular(2.r), + ), +) +``` +这是 iOS/微信风格的"可拖拽暗示":看到它就明白面板能拉。 + +### 4. 非模态:面板盖住背景,但不拦截背景交互 +`PersistentSnapSheet` 是**常驻布局组件**(不是 `showModalBottomSheet` 弹窗), +面板下面的地图照常能点——这是"常驻面板"和"模态弹层"的本质区别。 + +--- + +## 四、关键代码剖析 + +```dart +/// 面板内部内容:分割线 + 可滚动学校列表(与面板手势联动) +Expanded( + child: ListView.separated( + padding: EdgeInsets.symmetric(horizontal: 16.w), + itemCount: _schools.length, + separatorBuilder: (_, _) => Divider(height: 1.h, color: const Color(0xFFF1F5F9)), + itemBuilder: (context, index) => _SchoolListTile( + school: _schools[index], + index: index, + ), + ), +), +``` + +- 面板里放 **Expanded + ListView**:面板多高,列表多高; +- 列表滚到顶部继续下拉 → 手势被组件接住 → 面板收起(smooth_sheets 的核心联动,本页零代码实现); +- 列表项 `_SchoolListTile` 用图标 + 双行文本 + 导航按钮组成,颜色按 `index % 8` 轮换(8 色数组)。 + +--- + +## 五、踩坑记录 + +1. **snapFactors 是屏高比例,不是固定像素**——改屏幕/横竖屏后停靠点自动适配,别写死 300.0 这类像素值; +2. 想"盖住背景不可交互"的场景(确认弹窗)**别用 PersistentSnapSheet**,用 `showModalBottomSheet`(见 [note_picker_demo.md](note_picker_demo.md) 的选图弹层); +3. 面板内滚动列表必须用 **Expanded 包裹**,否则列表高度 = 内容高度,滚不动; +4. 手柄区域 `HitTestBehavior.opaque` 保证空白处也能接住拖拽手势; +5. 真实项目把 `_buildMapBackground` 换成高德/百度地图 SDK 组件即可,面板层完全不用动。 + +--- + +## 六、扩展思考 + +- 对比模态弹层:`showModalBottomSheet` 的用法见 [note_picker_demo.md](note_picker_demo.md)(PickerService 内部); +- 面板里的列表复用 [list_demo.md](list_demo.md) 的 `ListView.separated` 知识点; +- 面板内容进阶:搜索框 + 学校筛选(联动 [input_demo.md](input_demo.md) 的 TextField 知识); +- 组件本体 `PersistentSnapSheet` 在 `lib/core/components/persistent_snap_sheet.dart`——读它 = 学 smooth_sheets 包的使用。 diff --git a/docs/demos/stack_demo.md b/docs/demos/stack_demo.md new file mode 100644 index 0000000..2a6dfd1 --- /dev/null +++ b/docs/demos/stack_demo.md @@ -0,0 +1,119 @@ +# 【Demo】Stack + Positioned 层叠布局 + +> 源文件:`lib/pages/demo/stack_demo.dart` | 分类:基础布局 +> 一句话:UI 重叠 = CSS 的 `position: absolute`,本页附赠一个真实踩过的"全 Positioned 尺寸崩溃"大坑。 + +--- + +## 一、这个 Demo 解决什么问题 + +做"头像 + 右上角角标"、"卡片 + 半透明蒙层"、"悬浮按钮"这类**元素重叠**的 UI。 +Flutter 用 `Stack` 实现: +- `Stack` = 图层容器(**后写的子组件盖在上面**); +- `Positioned` = 给子组件做绝对定位(**只能直接放在 Stack 的 children 里**)。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: StackDemoPage())); +``` + +页面演示 5 块内容: +1. 基础重叠:蓝色盒子 + 右上角悬空角标 + 居中文字; +2. `Positioned.fill` 半透明蒙层; +3. 坑①复现:全 Positioned 时 Stack 尺寸异常; +4. 修复方案 1:SizedBox 固定宽高; +5. 修复方案 2:非 Positioned 底层组件撑尺寸。 + +--- + +## 三、核心知识点(新手向) + +### 1. Stack 的两个核心参数 +```dart +Stack( + alignment: Alignment.topLeft, // 对【没被 Positioned 包裹】的子组件做默认对齐 + clipBehavior: Clip.none, // 允许子组件绘制超出 Stack 边界(默认 Clip.hardEdge 会裁剪) + children: [...], +) +``` + +### 2. Positioned 的用法 +```dart +Positioned( + top: 10, // 距 Stack 上边 + right: -10, // 负值 = 超出边界(配合 clipBehavior: Clip.none 才能显示) + child: ..., +) + +Positioned.fill( // 填满整个 Stack —— 做蒙层最方便 + child: Container(color: Colors.black54), +) +``` + +### 3. 布局规则一句话 +Stack 的尺寸 = **非 Positioned 子组件**决定的(取其中最大的那个); +Positioned 组件只是"贴"在上面,不参与 Stack 自身尺寸计算。 + +--- + +## 四、关键代码剖析(含大坑) + +### 坑①:全部子组件都是 Positioned → Stack 尺寸异常 +```dart +// 真实行为:Stack 尺寸不由内容决定,直接取父约束最大值(constraints.biggest) +Stack( + children: [ + Positioned(top: 10, left: 10, child: Text("A")), + ], +) +``` +- 放在**有界约束**里(如 SizedBox 300×60)→ Stack 占满整个 300×60,而不是收缩到文字大小; +- 裸放在 **Column 等无界高度**里 → debug 模式直接断言崩溃: + `A Stack requires bounded constraints`,**整个 body 都画不出来**(只看到标题栏)。 + +### 修复方案 1:给 Stack 套 SizedBox 固定宽高 +```dart +SizedBox( + width: 300.w, + height: 80.h, + child: Stack(children: [Positioned(top: 10, left: 10, child: Text("A"))]), +) +``` + +### 修复方案 2:放一个非 Positioned 的底层组件撑起大小 +```dart +Stack( + children: [ + ColoredBox( // 非 Positioned 底层组件:由它决定 Stack 尺寸 + color: Color(0xFFE8E8E8), + child: SizedBox(width: 300.w, height: 80.h), + ), + Positioned(top: 10, left: 10, child: Text("A")), + ], +) +``` + +> 经验:**Stack 里永远留一个"地基"**(非 Positioned 的底层组件或外层 SizedBox), +> 既避免崩溃,也方便后续加 Positioned 不担心尺寸突变。 + +--- + +## 五、踩坑记录 + +1. **Positioned 只能直接作为 Stack 的 children**,中间套了其他 Widget 会失效(编译警告)。 +2. **做悬浮角标务必 `clipBehavior: Clip.none`**,否则负偏移部分被裁掉看不见。 +3. **全 Positioned 的 Stack 在无界约束下崩溃**(见上),这是本页作者真实遇到过的 bug:页面只剩标题。 +4. `Container` 在 Flutter 3.44 已不是 const 构造,外层 `Center` 别写 `const`(编译报错)。 +5. 想让内容很短也能拉动滚动(演示页效果),用 `physics: BouncingScrollPhysics()` / `AlwaysScrollableScrollPhysics()`。 + +--- + +## 六、扩展思考 + +- 蒙层进阶:`Positioned.fill` + 半透明色 + 点击手势 = 简易弹窗/引导层; +- 角标进阶:红点角标(Badge 组件)本质也是 Stack + Positioned 的封装; +- 与 [list_demo.md](list_demo.md) 对照:列表右下角的"回到顶部"FAB 就是 `Stack + Positioned` 实现的; +- 与 [smooth_sheet_demo.md](smooth_sheet_demo.md) 对照:地图背景 + 底部面板就是"Stack 背景层 + 面板层"的组合。 diff --git a/docs/demos/text_demo.md b/docs/demos/text_demo.md new file mode 100644 index 0000000..31eaade --- /dev/null +++ b/docs/demos/text_demo.md @@ -0,0 +1,148 @@ +# 【Demo】Text 文本组件全家桶 + +> 源文件:`lib/pages/demo/text_demo.dart` | 分类:基础布局 +> 一句话:样式、对齐、截断、行高、富文本、可点击协议文本 —— 文本相关的细节一次看全。 + +--- + +## 一、这个 Demo 解决什么问题 + +Text 是 Flutter 里出现频率最高的组件,但细节坑不少: +- `textAlign` 为什么有时不生效? +- 超长文字怎么显示省略号? +- "已阅读并同意《用户协议》"这种**部分文字可点击**怎么做? +- 系统字体缩放怎么适配? + +本页 7 个区块逐一演示。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: TextDemoPage())); +``` + +1. 基础样式(fontSize / color / fontWeight / fontStyle); +2. 对齐 textAlign(左/中/右); +3. 截断三件套(ellipsis / clip / softWrap:false); +4. 行高 height 对比(默认 / 1.2 / 2); +5. 字间距 letterSpacing / 词间距 wordSpacing; +6. 富文本 Text.rich + TextSpan + 可点击协议; +7. 字体缩放 textScaler。 + +--- + +## 三、核心知识点(新手向) + +### 1. Text 全家桶速记 +| 场景 | 组件 | +|---|---| +| 单样式文字 | `Text` | +| 富文本拼接(不同颜色/可点击段落) | `Text.rich` + `TextSpan` | +| 全局默认样式 | `DefaultTextStyle`(MaterialApp 自带,Text 会继承) | + +### 2. textAlign 生效的前提 +**Text 必须有宽度约束**,对齐才有空间生效: +```dart +Container( + width: 300.w, + child: Column( + // 关键:stretch 让每个子组件横向拉满,textAlign 才有空间生效 + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('左对齐', textAlign: TextAlign.left), + Text('居中对齐', textAlign: TextAlign.center), + Text('右对齐', textAlign: TextAlign.right), + ], + ), +) +``` +> 新手最常见的疑惑:在 Column 里写 `textAlign: center` 不居中 —— 因为 Text 宽度只包住文字。 +> 要么 `crossAxisAlignment: center`(让 Text 居中),要么 `stretch` + `textAlign`(让文字在拉满的宽度里居中)。 + +### 3. 截断三件套(必须配齐) +```dart +Text( + '很长很长...', + maxLines: 1, + overflow: TextOverflow.ellipsis, // 末尾加省略号 +) +``` +- `ellipsis`:省略号;`clip`:直接裁掉;`softWrap: false` + `visible`:真·溢出画到盒子外; +- 前提同样是**有界宽度**(外面套固定宽 Container)。 + +### 4. 行高与间距 +```dart +TextStyle(height: 1.2) // 行高倍数(1.0 = 紧凑,2.0 = 松散) +TextStyle(letterSpacing: 4) // 字间距(中文按字) +TextStyle(wordSpacing: 8) // 词间距(按空格分词,中文基本无效) +``` + +### 5. 富文本 + 可点击(协议文本的官方姿势) +```dart +Text.rich( + TextSpan( + children: [ + TextSpan(text: '已阅读并同意', style: TextStyle(color: Color(0xFF888888))), + TextSpan( + text: '《用户协议》', + style: TextStyle(color: Color(0xFF3B82F6), fontWeight: FontWeight.bold), + recognizer: _agreementRecognizer, // 点击手势(必须 dispose!) + ), + ], + ), +) +``` +`TapGestureRecognizer` 必须放在 **StatefulWidget 里创建,并在 dispose() 里释放**(与 createState 一一对应,防内存泄漏)。 + +### 6. 字体缩放(Flutter 3.x) +```dart +Text('线性放大 1.5 倍', textScaler: TextScaler.linear(1.5)); +// 读取系统缩放:MediaQuery.textScalerOf(context) +``` + +--- + +## 四、关键代码剖析 + +```dart +class _AgreementRichTextState extends State<_AgreementRichText> { + // late final:整个 State 生命周期只创建一次,复用同一个 recognizer + late final TapGestureRecognizer _agreementRecognizer = TapGestureRecognizer() + ..onTap = _handleAgreementTap; + + void _handleAgreementTap() { + // 点击回调可能晚于 dispose(点击瞬间页面被切走),用 mounted 兜底 + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('点了《用户协议》')), + ); + } + + @override + void dispose() { + _agreementRecognizer.dispose(); // 关键:释放 recognizer + super.dispose(); + } +} +``` + +--- + +## 五、踩坑记录 + +1. **`textScaleFactor` 已废弃**(Flutter 3.x)→ 用 `textScaler` / `MediaQuery.textScalerOf(context)`; +2. **`withOpacity` 已废弃** → 用 `withValues(alpha: x)`; +3. textAlign 不生效 = 没有宽度约束(用 stretch 或固定宽); +4. 截断不生效 = 没有 `maxLines`(三件套要配齐); +5. `TapGestureRecognizer` 不 dispose 会内存泄漏(页面反复进出后崩溃/卡顿)。 + +--- + +## 六、扩展思考 + +- 富文本进阶:本项目的 `RichTextContent`([rich_text_demo.md](rich_text_demo.md))渲染 HTML 富文本(题目/解析), + 底层就是 TextSpan 体系的自动生成; +- 全局字体缩放:`MaterialApp(builder: ...)` 里统一包 `MediaQuery` 改造,实现"阅读字号调节"(见 rich_text_demo 的 Aa±); +- 文本选择:`SelectableText` 让用户可复制(本项目的日志区就用它)。 diff --git a/docs/demos/theme_demo.md b/docs/demos/theme_demo.md new file mode 100644 index 0000000..c2f7dc0 --- /dev/null +++ b/docs/demos/theme_demo.md @@ -0,0 +1,128 @@ +# 【Demo】品牌换肤(两级主题模型) + +> 源文件:`lib/pages/demo/theme_demo.dart` | 分类:基础布局 +> 一句话:换品牌 = 一级换整套色板 + 二级换页面细节,一套代码全 App 变色。 + +--- + +## 一、这个 Demo 解决什么问题 + +教育 App 常有"多品牌运营"需求:不同机构定制不同主色、页面配色、甚至标题文案。 +本 Demo 演示项目核心的**两级主题模型**: + +``` +一级 = 品牌:seedColor 生成整套 Material 色板(全 App 共享) +二级 = 页面细节(PageTheme):按页面层级(home/detail/result) + 区分背景色 / 头部色 / 强调色 / 专属 banner / 标题文案 +``` + +页面只声明"我是哪个层级",细节从品牌对象按层级查出来: +**换品牌 → 一级色板全换 + 二级细节全换(一套代码)**。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: ThemeDemoPage())); +``` + +- 顶部 ChoiceChip 切换品牌(从注册表 `brandThemes` 自动生成按钮,新增品牌自动出现); +- "一级"区:显示 seed 色与生成的 primary 对比 + 跟随主题的按钮/进度条; +- "二级"区:home/detail/result 三个层级各渲染一个页面骨架预览(背景/头部/强调色/文案全跟随品牌)。 + +--- + +## 三、核心知识点(新手向) + +### 1. 主题的两层结构(模型已解耦,见 `core/theme/`) +| 层 | 内容 | 存放 | +|---|---|---| +| 模型 + 注册表 | `BrandTheme` / `PageTheme` / `PageLevel` + `brandThemes` | `core/theme/brand_theme.dart` | +| 每品牌配置 | 只有具体色值/文案 | `core/theme/brands/xxx_theme.dart` | + +新增一个品牌 = 新建一个配置文件 + 注册表加一行,**业务代码零改动**。 + +### 2. 一级:colorSchemeSeed 的原理 +```dart +ThemeData(colorSchemeSeed: _brand.seedColor) +``` +传一个种子色,Material 自动生成整套 ColorScheme(primary/secondary/tertiary...), +**没写死颜色的 Material 组件全部自动跟随**(FilledButton/Checkbox/Switch/进度条/聚焦输入框)。 +验证页见 [seed_palette_demo.md](seed_palette_demo.md)。 + +> ⚠ colorSchemeSeed 生成的 primary 不是精确色值(Material 会调和), +> 要求精确用 `ColorScheme.fromSeed(...)` 后手动覆盖 primary。 + +### 3. 二级:PageTheme 按层级查询 +```dart +// 页面只声明层级(本 demo 直接取,真实项目从路由/参数拿) +final page = _brand.pages[PageLevel.home]!; +// 使用:背景 page.bg、头部 page.headerBg、强调 page.accent、banner page.banner +``` +- banner 存资源路径(如 `assets/img/banner_sky.png`),页面 `Image.asset(page.banner!)` 即可跟随换图; +- 本 demo 没准备两套图片,用"强调色渐变块"模拟 banner 占位(代码里标注了替换点)。 + +### 4. 局部主题覆盖(不依赖全局 MaterialApp 的切换法) +```dart +return Theme( + data: ThemeData(colorSchemeSeed: _brand.seedColor), + child: Scaffold( ... ), // 页面内组件都从局部主题取色 +); +``` +> 真实项目把 `_brand` 换成 Riverpod Provider + MaterialApp 的 theme,切换点移到根层。 + +--- + +## 四、关键代码剖析 + +```dart +// 品牌切换(注册表驱动,新增品牌自动出现) +Wrap( + spacing: 8, + children: [ + for (final b in brandThemes) + ChoiceChip( + label: Text(b.name), + selected: _brand.name == b.name, + onSelected: (_) => setState(() => _brand = b), + ), + ], +) + +// 二级细节消费示例(页面骨架预览) +Container( + color: page.bg, // ← 二级:背景 + child: Column(children: [ + Container( + color: page.headerBg, // ← 二级:头部色 + child: Text(page.title), // ← 二级:标题文案 + ), + // banner 占位:真实项目替换成 Image.asset(page.banner!) + Container(decoration: BoxDecoration(gradient: LinearGradient(colors: [page.accent, page.headerBg]))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: page.accent), // ← 二级:强调色 + child: const Text('本页主按钮'), + ), + ]), +) +``` + +--- + +## 五、踩坑记录 + +1. **colorSchemeSeed 生成的 primary 不精确** —— 需要精确色值时手动覆盖; +2. **局部 `Theme(data:)` 必须包在 Scaffold 外面**,AppBar 才能取到(包在 body 里 AppBar 取不到); +3. **PNG 位图不跟主题着色** —— 能用矢量(IconData)就别放多份图; +4. 深色模式要单独配 `darkTheme` + `themeMode`,不是简单换个 seed; +5. `.w/.h/.sp` 的组件不能 const(`const_eval_extension_method`)。 + +--- + +## 六、扩展思考 + +- 持久化:品牌选择存 shared_preferences,启动时读出来传给 MaterialApp 的 theme; +- 动态切换动画:`AnimatedTheme` 包 MaterialApp,换主题有过渡动画; +- 与 [seed_palette_demo.md](seed_palette_demo.md) 对照:一个验证"seed 生效",一个演示"两级模型的消费"; +- 更重的多主题需求可引入 `flex_color_scheme` 包,本项目的轻量模型够用且无依赖。 diff --git a/docs/demos/wrap_demo.md b/docs/demos/wrap_demo.md new file mode 100644 index 0000000..56f86e7 --- /dev/null +++ b/docs/demos/wrap_demo.md @@ -0,0 +1,144 @@ +# 【Demo】Wrap 自动换行布局 + Chip 标签家族 + +> 源文件:`lib/pages/demo/wrap_demo.dart` | 分类:基础布局 +> 一句话:Row 一行排不下就溢出报错,Wrap 会自动换行 —— 做"标签流"必备。 + +--- + +## 一、这个 Demo 解决什么问题 + +"搜索历史标签"、"科目标签"、"技能标签"这类**数量不固定**的横向排列场景: +- `Row` 一行排不下会**直接溢出报错**(黄色条纹警告),不会换行; +- `Wrap` 空间不够时**自动换到下一行**,是标签流的正解。 + +本 Demo 还完整演示了 Flutter 的 **Chip 标签家族**:`Chip` / `ActionChip` / `FilterChip`, +以及一个**像素级可控的自绘标签 `CustomTag`**(设计稿要求精确高度/间距时的选择)。 + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: WrapDemoPage())); +``` + +页面是一个 Wrap,里面混排了 8 种 Chip 样式 + 自绘 CustomTag: +带图标、可删除、可点击、选中态、深色底、胶囊圆角……一屏看完所有变体。 + +--- + +## 三、核心知识点(新手向) + +### 1. Wrap 的核心属性 +```dart +Wrap( + direction: Axis.horizontal, // 排布方向(默认水平) + spacing: 24, // 同一行内 item 之间的水平间距 + runSpacing: 20, // 行与行之间的垂直间距(run = "行/批") + children: [...], +) +``` + +### 2. 对比记忆 +| 组件 | 行为 | +|---|---| +| `Row` | 横向排列,**溢出报错**,不换行 | +| `Wrap` | 横向排列,**空间不足自动换行** | +| `Column` | 纵向排列,溢出报错(想纵向换行用 `Wrap(direction: Axis.vertical)`) | + +### 3. Chip 家族速查(Flutter 3.x 注意!) +| 组件 | 用途 | 必填回调 | +|---|---|---| +| `Chip` | 纯展示 / 可删除标签 | `onDeleted` 可选 | +| `ActionChip` | 可点击的标签(**旧 `Chip.onPressed` 已移除**) | `onPressed` | +| `FilterChip` | 带选中态的标签(**旧 `Chip.selected` 已移除**) | `onSelected` | + +```dart +// 可删除 +Chip( + label: const Text("Flutter"), + deleteIcon: Icon(Icons.close, size: 16.w), + onDeleted: () => print("删除 Flutter"), +) + +// 可点击(Flutter 3.x 用 ActionChip) +ActionChip( + label: const Text("UniApp"), + onPressed: () => print("点击 UniApp"), +) + +// 选中态(Flutter 3.x 用 FilterChip) +FilterChip( + label: const Text("JavaScript"), + selected: true, + selectedColor: const Color(0xFF4CAF50), + onSelected: (_) {}, +) +``` + +### 4. 自绘标签 CustomTag 为什么存在 +标准 Chip 的高度/内边距是 Material 规范定的,**设计稿要求"固定 24px 高、图标与文字精确 4px"时**, +Chip 调不精确 —— 这时用 `Container + Row` 手写一个,完全可控: +- `InkWell` 提供点击涟漪(`borderRadius` 让涟漪跟随圆角); +- `TextStyle(height: 1)` 去掉文字默认行高,避免把容器撑高。 + +--- + +## 四、关键代码剖析 + +```dart +// 自绘标签骨架(像素级可控的核心结构) +return Material( + color: Colors.transparent, // 消除 InkWell 默认的白底 + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4.r), + child: Container( + height: 24.h, // 精确高度 + padding: EdgeInsets.only(left: 6.w, right: 6.w), + decoration: BoxDecoration( + color: const Color(0xFFEDF2FF), + borderRadius: BorderRadius.circular(4.r), + border: Border.all(color: const Color(0xFFD6E4FF)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 12.w, color: iconColor), + SizedBox(width: 4.w), // 图标与文字精确 4px + ], + Text(label, style: TextStyle(fontSize: 12.sp, height: 1)), + if (onDeleted != null) ...[ + SizedBox(width: 2.w), + GestureDetector(onTap: onDeleted, child: Icon(Icons.close, size: 12.w)), + ], + ], + ), + ), + ), +) +``` + +要点: +- `Material + InkWell` 组合才有水波纹;`InkWell` 直接包 `Container` 时涟漪会被容器背景盖住; +- 用 `if (xx != null) ...[]` 实现"可选 icon / 可选删除叉"——传 `null` 就不渲染。 + +--- + +## 五、踩坑记录 + +1. **`Chip.onPressed` / `Chip.selected` 在 Flutter 3.x 已移除**,分别改用 `ActionChip` / `FilterChip`(旧代码会编译报错)。 +2. `Wrap` 外层要给它**有界的宽度**(比如直接当 body 用,或放 `Padding` 里),否则换行无从谈起。 +3. 自绘标签里文字务必 `height: 1`,否则默认行高会让"24px 高"变成"30px+ 高"。 +4. `print` 只适合 Demo 调试,真实项目用 `debugPrint` 或日志组件(见 dio_demo 的注释)。 + +--- + +## 六、扩展思考 + +- **`Flow`**:Wrap 的高阶版,自定义换行算法(间距均匀分布、瀑布流),性能更好但代码量大; +- 标签流 + 搜索历史的真实场景:数据存本地(shared_preferences)→ 读取渲染 Wrap → 点击标签直接搜索; +- Chip 的 `shape: StadiumBorder()` = 胶囊全圆角,`avatar` 放小图标、`labelPadding` 控制文字内边距; +- 本项目 `theme_demo` 里的品牌切换、`note_picker_demo` 里的"加号格",都用了 Wrap/网格思路,可对照学习。 + diff --git a/docs/demos/wrong_flow_demo.md b/docs/demos/wrong_flow_demo.md new file mode 100644 index 0000000..c1af6ac --- /dev/null +++ b/docs/demos/wrong_flow_demo.md @@ -0,0 +1,128 @@ +# 【Demo】接口 + Isar + watch 完整链路(答错记错题) + +> 源文件:`lib/pages/demo/wrong_flow_demo.dart` | 分类:存储 +> 一句话:回答"现在不都调接口吗?" —— 接口、本地库、响应式三层协作的真实业务链路。 + +--- + +## 一、这个 Demo 解决什么问题 + +答错一题,要同时完成三件事: +1. **接口上报**(远端):服务端记录错题 —— 可能失败、可能慢; +2. **本地落库**(Isar):错题本快照,**离线也能看**; +3. **watch 响应式**:本地库一变,错题列表自动刷新。 + +``` +流程:答错 → ① 上报接口(失败也继续)→ ② 写本地错题本 → ③ watch 自动推送 → 列表自动更新 +``` + +--- + +## 二、怎么运行 / 演示内容 + +```dart +runApp(const MaterialApp(home: WrongFlowDemoPage())); +``` + +- 三道内置题,答错 → 完整链路日志(接口 → 本地 → watch); +- "模拟断网"按钮:跳过接口,只写本地(演示离线兜底); +- 下方错题本列表:watch 订阅,零手动刷新。 + +> ⚠ 依赖 Isar(native only),Web 端降级提示。 + +--- + +## 三、核心知识点(新手向) + +### 1. 为什么接口失败本地仍记录(离线兜底) +学生端弱网是常态。**错题是"本地刚需数据"**——先落库先能用; +联网后再**增量同步**回服务端(真实项目用 sync 队列,错题加 `isSynced` 字段标记, +本例为演示未做,代码里有注释)。 + +```dart +// ① 上报接口:失败也继续(本地兜底) +await _reportToServer(wrongAnswer: _options[index]); +// ② 写本地错题本:watch 自动推送 → 下方列表自动更新 +await WrongQuestionStore.instance.add( + questionId: 100 + _qIndex, + title: _question, + wrongAnswer: _options[index], +); +``` + +### 2. 别把本地写入放接口 onError 里! +```dart +try { + await DioClient.instance.post('/posts', data: {...}); + _appendLog('📡 接口上报成功'); +} on ApiException catch (e) { + // 接口失败不阻断:本地仍要记,联网后再同步 + _appendLog('📡 接口上报失败:${e.message}'); + _appendLog(' ↳ 本地仍记录,联网后增量同步(sync 队列)'); +} finally { + if (mounted) setState(() => _reporting = false); +} +``` +**独立的两步**:上报和落库互不依赖。 +如果写在 onError 里,就成了"只有失败才记录"——在线答错的题反而不进错题本,逻辑就反了。 + +### 3. 对比"纯接口 + 手动刷新" +不 watch 就得:答完错题 → 手动通知错题 tab 刷新(事件总线/页面重建)。 +watch 让 UI 直接订阅数据库,**写入和展示彻底解耦**。 + +### 4. Dart 3 记录类型题库 +```dart +// 题库:(题干, 选项, 正确答案索引) +static const List<(String, List, int)> _bank = [ + ('7 × 8 = ?', ['54', '56', '58', '63'], 1), + ('Flutter 官方推荐的状态管理?', ['Provider', 'Riverpod', 'BLoC', 'GetX'], 1), +]; + +String get _question => _bank[_qIndex].$1; // 题干 +List get _options => _bank[_qIndex].$2; // 选项 +int get _rightIndex => _bank[_qIndex].$3; // 正确答案索引 +``` + +--- + +## 四、关键代码剖析 + +```dart +// 答题:对 → 不记录;错 → 接口上报 + 本地错题(watch 自动刷新) +Future _onPick(int index) async { + if (_reporting) return; // 上报中防重入 + final isRight = index == _rightIndex; + setState(() { _selected = index; _isRight = isRight; }); + + if (isRight) { + _appendLog('✅ 回答正确,不记错题'); + } else { + _appendLog('❌ 答错「${_question}」(选了 ${_options[index]}),进入链路...'); + await _reportToServer(wrongAnswer: _options[index]); // ① 上报 + await WrongQuestionStore.instance.add(...); // ② 落库 + _appendLog('💾 已写入本地错题本 → watch 自动刷新列表'); + } + + if (!mounted) return; + setState(() { _qIndex = (_qIndex + 1) % _bank.length; _selected = null; _isRight = null; }); +} +``` + +--- + +## 五、踩坑记录 + +1. **watch 只在 native 可用**(web 空实现),本页 `kIsWeb` 降级提示; +2. **写库必须在 writeTxn 内**(store 已封装); +3. **接口上报和本地写入是两个独立步骤**,别把本地写入放接口 onError 里; +4. 上报中要防重入(`_reporting` 禁用选项点击),否则连点会重复上报; +5. 异步(await)之后 setState 前判 mounted。 + +--- + +## 六、扩展思考 + +- **sync 队列进阶**:错题表加 `isSynced` 字段 → 启动/联网时查 `isSynced == false` 批量补报 → 成功后置位; +- 与 [isar_watch_demo.md](isar_watch_demo.md) 对照:一个讲"watch 本身",一个讲"完整业务链路"; +- 上报失败的重试策略可复用 `DioClient` 的指数退避(见 [dio_pro_demo.md](dio_pro_demo.md)); +- 真实项目:本页的 `_bank` 换成接口拉取的题,`WrongQuestionStore.add` 换成带 userId 的写入。 diff --git a/docs/exam_architecture.md b/docs/exam_architecture.md new file mode 100644 index 0000000..f5f3051 --- /dev/null +++ b/docs/exam_architecture.md @@ -0,0 +1,605 @@ +# 试卷练习功能架构设计 + +> 本文档是 `features/exam/` 试卷练习功能(PageView.builder 大数据滑动 + 题型分发 + 续答持久化)的架构说明。 +> 面向 Flutter 初学者,重点解释"每一层为什么这么设计"。 +> **没学过 Riverpod / Isar 的,先读「零、新手前置」教程,再看架构;** +> 阅读前提:了解本项目技术栈(Riverpod / Isar / go_router,见 pubspec.yaml 注释)。 + +--- + +## 零、新手前置:Riverpod 与 Isar 使用教程 + +> 本章是**工具用法入门**(面向 Flutter 新手),看完能读懂本项目所有 provider 和 store 代码; +> 后面一~九章是架构设计。建议阅读顺序:先学本章 → 再看架构 → 最后动手改代码。 +> 所有示例都是本项目真实代码,可以直接打开对应文件对照。 + +### 0.1 Riverpod 状态管理怎么用 + +#### 0.1.1 它解决什么问题 + +Flutter 的界面是 `Widget` 树。数据如果存在组件内部,组件一销毁数据就没了。 +Riverpod(本项目 3.3.2,flutter_riverpod 包)把"数据"提升到组件树之外的全局容器(provider)里, +界面通过"订阅"拿到数据,**数据一变界面自动重建**——这就是响应式。 + +对标 Vue3:`provider ≈ Pinia store`,`ref.watch ≈ storeToRefs / computed`,`ref.read(provider.notifier).方法() ≈ store action`。 + +#### 0.1.2 上手第一步:包根(main.dart 已写好) + +```dart +// main.dart(本项目实际代码,最后一行) +runApp(const ProviderScope(child: TabApp())); +``` + +`ProviderScope` 是所有 provider 的容器,必须包在最外层。**新建项目时这一步不能忘**, +否则任何页面 `ref.watch` 都会报 "ProviderScope is missing"。 + +#### 0.1.3 上手第二步:定义一个"同步状态"(答案数据,最小完整示例) + +```dart +// answer_provider.dart(本项目实际代码) +// ① 声明 provider:,构造函数引用传入 +final answerProvider = + NotifierProvider>(AnswerNotifier.new); + +// ② 写 Notifier 类:状态在这里定义、修改 +class AnswerNotifier extends Notifier> { + @override + Map build() => {}; // 初始状态:空 Map + + void answer(int questionId, String value) { + state = {...state, questionId: value}; // ③ 修改:必须整体赋新值! + } + + void clear() => state = {}; // 重置 +} +``` + +新手必须记住的规则: +- `build()` 返回初始状态; +- **修改状态永远用 `state = 新值`**,不能 `state.xxx = ...`。Dart 的 Map 是引用类型, + 直接改内部元素不会触发通知——所以要 `{...state, ...}` 展开成新 Map 再整体赋值; +- Notifier 是 Riverpod 3.x 的**推荐写法**(状态 + 操作方法封装在一个类里)。 + 老教程里的 `StateProvider` 已移入 legacy,不要学。 + +#### 0.1.4 上手第三步:定义一个"异步状态"(题目数据,接口加载场景) + +```dart +// paper_provider.dart(本项目实际代码,mock 分支) +final paperProvider = + AsyncNotifierProvider>(PaperNotifier.new); + +class PaperNotifier extends AsyncNotifier> { + @override + Future> build() async { + await Future.delayed(const Duration(milliseconds: 600)); // 模拟接口延迟 + return MockExamData.generate(100); + } +} +``` + +和同步版本的区别只有一个:`build()` 返回 `Future`。Riverpod 会自动把过程包装成 `AsyncValue`—— +它自带三态:`loading`(加载中)/ `error`(失败)/ `data`(成功)。页面这样消费: + +```dart +// exam_page.dart(本项目实际代码,简化) +final paperAsync = ref.watch(paperProvider); // AsyncValue> +return paperAsync.when( + loading: () => const CircularProgressIndicator(), // ① 加载中 → 转圈 + error: (e, _) => Text('试卷加载失败:$e'), // ② 失败 → 报错文案 + data: (questions) => PageView.builder(...), // ③ 成功 → 出卷 +); +``` + +**怎么选 Async 还是普通 Notifier**:初始化需要"等一下"(接口 / 数据库查询)→ 用 Async;纯同步内存状态 → 用 Notifier。 + +#### 0.1.5 页面怎么读:watch / read 区别(最高频考点) + +```dart +class _ExamPageState extends ConsumerState { + @override + Widget build(BuildContext context) { + final answers = ref.watch(answerProvider); // ① watch:订阅,变化就重建 + // ... + onAnswer: (v) => ref.read(answerProvider.notifier) // ② read:只调方法,不订阅 + .answer(q.questionId, v), + } +} +``` + +- `ref.watch(provider)`:**订阅**。状态一变,build 重新执行(界面自动更新)。**只能写在 build 方法里**。 +- `ref.read(provider)`:**一次性读取**,不订阅。适合事件回调、initState。 +- 改状态:`ref.read(provider.notifier).方法()`(见上)。 + +回调里写 `ref.watch` 会直接报错("watch 不能在 build 之外使用")——新手最常见的报错之一。 + +#### 0.1.6 本项目 provider 一览(学完自检清单) + +| provider | 类型 | 状态 | 用途 | +|---|---|---|---| +| `paperProvider` | AsyncNotifierProvider | `List` | 题目(异步加载) | +| `answerProvider` | NotifierProvider | `Map` | 答案(同步读写) | +| `paperSizeProvider` | NotifierProvider | `int` | 生成题数(50/100/200) | +| `privacyAcceptedNotifier` | NotifierProvider(core/providers/) | `bool` | 隐私同意状态 | + +#### 0.1.7 新手四个高频误区 + +1. **别用 setState 存全局数据**:PageView 会回收滑出的页面,组件销毁数据就丢 → 数据上 provider(答案放 provider 就是这个原因) +2. **watch 别用在回调里**:`onTap: () => ref.watch(x)` 编译报错 → 用 read +3. **state 要整体替换**:`state['k'] = v` 不会刷新界面 → 用 `state = {...state, 'k': v}` +4. **别让组件到处 watch**:组件里 watch 越多越难复用。本项目约定:只在 ExamPage 连接 provider,QuestionCard 收 props / 抛回调(受控组件模式) + +--- + +### 0.2 Isar 本地数据库怎么用 + +#### 0.2.1 它解决什么问题 + +Isar 是 Dart 原生的本地 NoSQL 数据库(对标 Vue 的 localStorage,但强类型、毫秒级查询、支持索引和事务)。 +存"**跨会话**"的数据——进程杀掉后还要用的:题库、错题、播放记录、答题进度。 + +本项目存储分三层(各管各的): + +| 层 | 工具 | 存什么 | +|---|---|---| +| ① | flutter_secure_storage | Token 等敏感凭据(加密) | +| ② | shared_preferences | 用户信息 / 开关等小配置(明文快读) | +| ③ | **Isar(AppDatabase)** | 题库 / 错题 / 播放记录 / 答题进度(大批量结构化数据) | + +**为什么用 isar_community 而不是 isar**:官方 isar 3.1.8 只发在已宕机的私有源(pub.isar-community.dev),装不上; +`isar_community`(本项目 3.3.2)是社区在 pub.dev 的维护版,API 与 isar 完全一致。 + +#### 0.2.2 四个固定步骤:建一个 Isar 功能 + +以本项目 AnswerRecord(答题进度)为例,完整走一遍: + +**第 1 步:建模型**(core/storage/models/answer_record.dart) + +```dart +import 'package:isar_community/isar.dart'; +part 'answer_record.g.dart'; // 代码生成文件(第 2 步跑出来) + +@collection // 注解:告诉生成器"这是个集合" +class AnswerRecord { + Id id = Isar.autoIncrement; // ⚠ 必须有主键(自动递增) + @Index() // 索引:按 paperId 查走索引(毫秒级) + int? paperId; + int? questionId; + String? answer; + DateTime? updatedAt; +} +``` + +三个规则:必须有 `@collection` 注解、必须有 `Id` 主键、必须 `part 'xxx.g.dart'`。 + +**第 2 步:跑代码生成**(每改一次模型字段都要重跑) + +```bash +dart run build_runner build +``` + +生成 `answer_record.g.dart`(里面是 `XxxSchema`——数据库的"表结构定义")。改字段后不重跑,编译直接报错。 + +**第 3 步:注册集合**(core/storage/database/app_database_io.dart 的 Isar.open) + +```dart +isar = await Isar.open( + [ + QuestionSchema, + WrongQuestionSchema, + PlayRecordSchema, + RemoteVersionEntitySchema, + AnswerRecordSchema, // ← 新集合加到这里 + ], + directory: dir.path, + name: 'app_db', + inspector: true, // debug 模式启用 Isar Inspector 调试页面(见 0.2.5) +); +``` + +**第 4 步:写读写代码**(core/storage/stores/answer_record_store_io.dart) + +```dart +// 写:必须包在 writeTxn 事务里(Isar 约束) +await db.isar.writeTxn(() async { + await db.answerRecords.put(AnswerRecord()..paperId = 1001); +}); + +// 查:where() 走索引,xxxEqualTo 由 @Index() 字段自动生成 +final list = await db.answerRecords + .where() + .paperIdEqualTo(1001) + .findAll(); + +// 删:条件批量删 +await db.answerRecords.where().paperIdEqualTo(1001).deleteAll(); +``` + +**核心 API 速查**: + +| 操作 | 代码 | +|---|---| +| 增 / 改(同 id 自动覆盖) | `writeTxn(() => collection.put(record))` | +| 按 id 查 | `collection.get(id)` | +| 条件查(走索引) | `collection.where().paperIdEqualTo(x).findAll()` | +| 查全部 | `collection.where().findAll()` | +| 数量 | `collection.count()` | +| 条件批量删 | `collection.where().xxxEqualTo(x).deleteAll()` | + +#### 0.2.3 web 兼容:为什么必须"条件导出"三件套(本项目最大坑) + +Isar 生成的 `*.g.dart` 里有一个 64 位 schema 哈希常量(如 -6819722535046815095), +JavaScript 最多只能精确表示 2^53 的整数 → **只要业务代码 import 了模型,web 编译必挂** +(报 "The integer literal ... can't be represented exactly in JavaScript")。 +即使运行时用 kIsWeb 跳过,**编译期**也过不了——因为静态 import 链已经把 .g.dart 拉进编译图了。 + +解法(本项目约定): +- 模型 / 真数据库 → 只被 `*_io.dart` import(仅 native 编译) +- 页面层 → 只 import 条件导出入口(如 answer_record_store.dart),这是页面唯一面对的接口 +- web 端 → `*_web.dart` 空实现(写入打印跳过、读取返回空),web 调试自动降级为纯内存 + +```dart +// answer_record_store.dart —— 页面的唯一入口 +// if (dart.library.io) 是【编译期】分发:native 编 io 版,web 编 web 版,运行期零开销 +export 'answer_record_store_web.dart' // web 端:空实现 + if (dart.library.io) 'answer_record_store_io.dart'; // 真机:真 Isar +``` + +#### 0.2.4 高频坑清单 + +1. **init 必须在 runApp 前 await**(main.dart 已写好):否则页面访问集合报 "Missing XxxSchema in Isar.open" +2. **写操作必须包 writeTxn**:Isar 的事务约束,忘包会抛错 +3. **改模型字段后必须重跑 build_runner**:`dart run build_runner build` +4. **业务页面永远不要 import 模型**:web 编译约束(见 0.2.3) +5. **io 实现里 import app_database_io.dart**(具体实现),不要 import app_database.dart(条件导出入口)—— + 分析器会检查所有条件分支,web 分支下入口解析成空实现,io 文件里调 db.isar 会报 undefined getter + +#### 0.2.5 调试利器:Isar Inspector + +`app_database_io.dart` 已开启 `inspector: true`。debug 模式跑真机 / 模拟器时, +浏览器打开 `http://localhost:8080` 就能可视化浏览、编辑数据库里的所有集合—— +查"答案到底存没存进去"用它最快,不用打日志。 + +--- + +## 一、概述 + +### 1.1 功能背景 + +原 Vue3 + uniapp 版 App 用 swiper 组件实现"左右滑动切换题目",题目数量大时出现性能瓶颈(swiper 会一次性把所有题目挂载到 DOM)。Flutter 版需要: + +- 大数组(100+ 题)滑动不卡 +- 支持单选 / 多选 / 判断 / 解答四种题型 +- 答题中途退出,下次进来能续答(答案持久化) +- 交卷后自动判分(客观题) + +### 1.2 核心思想 + +**四层架构 + 单向数据流**:UI 层永远不直接碰数据库,数据永远单向流动。 + +``` +数据库(Isar) → store → provider(状态) → UI(组件) → 用户操作 → provider → store → 数据库 +``` + +这条规则是整个架构的命脉,理解它就能理解所有文件的分工。 + +--- + +## 二、架构总览 + +``` +┌──────────────────────────────────────────────────────────┐ +│ ① Demo 层 pages/demo/exam_demo.dart │ +│ 入口:选择题目数量(50/100/200) → 跳转试卷页 │ +├──────────────────────────────────────────────────────────┤ +│ ② UI 层 features/exam/(只管"怎么显示") │ +│ exam_page.dart 试卷页:唯一连接状态的地方 │ +│ question_card.dart 题目卡片:纯展示组件 │ +│ answer_sheet_dialog.dart 答题卡:题号网格 + 跳题 │ +├──────────────────────────────────────────────────────────┤ +│ ③ 状态层 features/exam/(只管"状态放哪、怎么变") │ +│ exam_question.dart 题目接口模型(纯 Dart) │ +│ question_type.dart 题型枚举 │ +│ paper_provider.dart 题目数据(异步加载) │ +│ answer_provider.dart 答案数据(同步读写) │ +├──────────────────────────────────────────────────────────┤ +│ ④ 数据层 core/storage/(只管"怎么存") │ +│ models/answer_record.dart Isar 模型(续答持久化) │ +│ answer_record_store*.dart 三件套(条件导出) │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +## 三、逐层详解 + +### 3.1 数据层(core/storage/)——"数据怎么存" + +#### models/answer_record.dart + +```dart +@collection +class AnswerRecord { + Id id = Isar.autoIncrement; + @Index() + int? paperId; // 试卷 id(续答恢复/交卷清理的维度) + int? questionId; // 题目 id(对应题目模型的 questionId) + String? answer; // 用户答案(统一 String,见 5.1) + DateTime? updatedAt; // 作答时间(用于定位"最后作答的题") +} +``` + +**为什么需要它**:答案的主状态在 Riverpod 内存里,进程一杀就丢。本模型按"试卷"维度把每题答案落盘,实现续答。 + +**为什么 paperId 加 @Index()**:进卷恢复(`where(paperId).equalTo()`)和交卷清理都按它查,走索引毫秒级。 + +#### answer_record_store.dart 三件套(条件导出) + +| 文件 | 作用 | +|---|---| +| `answer_record_store.dart` | 条件导出入口,页面唯一面对的接口 | +| `answer_record_store_io.dart` | 真机实现:真 Isar 读写 | +| `answer_record_store_web.dart` | web 空实现:打印跳过,不碰 isar | + +```dart +// answer_record_store.dart —— 页面的唯一入口 +export 'answer_record_store_web.dart' // web 端:空实现 + if (dart.library.io) 'answer_record_store_io.dart'; // 真机:真 Isar +``` + +**为什么必须条件导出(本项目踩过的坑)**: +Isar 模型生成的 `*.g.dart` 含 64 位 schema 哈希常量,JavaScript 无法精确表示 → **业务代码只要 import Isar 模型,web 编译必挂**。web 版空实现不 import isar,编译通过;真机编译 io 版,功能完整。 + +**为什么接口用 dynamic 而不是模型类型**:模型 import 了 isar,web 端无法 import 模型,接口层返回 `List` 才能双端编译。 + +**为什么选 Isar 而不是 shared_preferences**: +- 项目存储分层约定(见 app_database_io.dart 注释):secure_storage 存凭据 / shared_preferences 存小配置 / Isar 存业务数据 +- 答案记录按 paperId 增删查的结构化需求,shared_preferences 全量重写 JSON 会越写越卡 +- 交卷批量清理:`where(paperId).deleteAll()` 一句话搞定 + +### 3.2 状态层(features/exam/)——"状态放哪、怎么变" + +#### exam_question.dart(纯 Dart 接口模型) + +**为什么不能用 Isar 的 Question 模型**:web 编译约束(见 3.1)。页面层需要强类型题目对象,但 Isar 模型进不了 web 编译图,所以定义纯 Dart 模型,字段与 Isar Question 对应(options 直接存 `List`,接口 JSON 本来就是数组)。 + +#### question_type.dart(题型枚举) + +```dart +enum QuestionType { single(1, '单选题'), multi(2, '多选题'), judge(3, '判断题'), essay(4, '解答题') } +``` + +**为什么用枚举不用裸 int**:枚举自带 label(UI 标签)和 code(存储值),页面不用散落魔法数字;加新题型只改这一个文件。 + +#### paper_provider.dart(题目数据) + +```dart +final paperProvider = AsyncNotifierProvider>(...); + +class PaperNotifier extends AsyncNotifier> { + static const bool mock = true; // 接口 mock 开关 + Future> build() async { ... } +} +``` + +- **为什么 AsyncNotifierProvider**:题目从接口加载是异步的,AsyncValue 自带 loading / error / data 三态,页面 `when()` 分支渲染 +- **为什么 mock 用 const bool**:对标 Vue3 内联 mock 体验,一行开关切换本地生成 / 真实接口,热重启即生效 +- **为什么题目只放内存不落 Isar**:题目是"会话数据"不是"资产",每次进卷拉最新(可能有勘误);100 题 ≈ 1MB,内存无压力 + +#### answer_provider.dart(答案数据) + +```dart +final answerProvider = NotifierProvider>(...); + +class AnswerNotifier extends Notifier> { + void answer(int questionId, String value) { + state = {...state, questionId: value}; // ① 内存主状态 + unawaited(AnswerRecordStore.instance.put(...)); // ② 异步落盘(fire-and-forget) + } + void restore(Map saved) { ... } // 续答恢复 + void clear() => state = {}; // 交卷清理 +} +``` + +**为什么答案必须放 provider 而不是组件 setState**:PageView.builder 懒加载,滑出的页面 Element 会被回收,答案存在组件里滑回来就丢了。provider 全局存活。 + +**为什么题目和答案分开两个 provider**:生命周期不同(题目只读一次 / 答案高频可写)。状态粒度越细,`ref.watch` 重建范围越小。 + +**为什么 key 用 questionId 而不是本地自增 id**:续答恢复时题目重新拉取也能对上。 + +### 3.3 UI 层(features/exam/)——"怎么显示" + +#### exam_page.dart(试卷页,唯一桥接层) + +整个功能只有这一个文件连接 Riverpod: + +```dart +final paperAsync = ref.watch(paperProvider); // 读题目(三态) +final answers = ref.watch(answerProvider); // 读答案(响应式) +... +QuestionCard( + key: ValueKey(q.questionId), // 按题号隔离,防页面复用串题 + question: q, // props 向下 + answer: answers[q.questionId], + onAnswer: (v) => ref.read(answerProvider.notifier).answer(q.questionId, v), // 事件向上 +) +``` + +职责清单: +- PageView.builder 组装(大数据滑动,见 5.2) +- 续答恢复:`_restoreProgress()` 读 Isar → `restore()` 注入 → `_tryJumpAfterRestore()` 定位到最后作答的题 +- 交卷:确认弹窗 → `deleteByPaper` 清理 → `clear()` 清内存 → 判分 +- 顶部进度条、底部导航(上一题/答题卡/下一题) + +**为什么设计成唯一桥接层**:状态流单一,出 bug 只检查一个文件;QuestionCard 不依赖 provider,可复用、可单测。 + +#### question_card.dart(纯展示组件) + +```dart +class QuestionCard extends StatelessWidget { + const QuestionCard({ + required this.question, // 数据来自父级 + required this.answer, // 数据来自父级 + required this.onAnswer, // 事件抛给父级 + }); + // 内部按 question.type switch 分发: + // single/multi → ChoiceQuestionView(共用选项布局,toggle 逻辑区分) + // judge → JudgeQuestionView(对/错两按钮) + // essay → EssayQuestionView(TextField + 300ms 防抖) +} +``` + +对应 Vue3 的 **props 向下 + emit 向上**(受控组件)。好处:可复用(错题重做、试卷回顾)、可单测、状态流单一。 + +**为什么解答题要 300ms 防抖**:onChanged 每敲一个字都会触发 provider 更新 + Isar 写库,防抖把高频输入合并为一次提交,减少写盘。 + +#### answer_sheet_dialog.dart(答题卡) + +题号网格(5 列)+ 状态色(已答蓝 / 当前绿 / 未答灰)+ 点击跳题(onJump 回调上抛,由 ExamPage 执行 jumpToPage)。 + +**为什么不直接碰 PageController**:跳转动作通过回调上抛,保持组件解耦,可复用于错题重做等场景。 + +### 3.4 Demo 层(pages/demo/) + +- `exam_demo.dart`:说明卡 + SegmentedButton 选题目数量(50/100/200)+ 开始按钮 +- 注册入口在 `features/task/task_page.dart` 的 demo 列表末尾(项目组件封装与 Demo 验证模式) + +--- + +## 四、核心数据流 + +### 4.1 进卷(两个异步并行,互不依赖) + +``` +用户点"开始练习" + ├─ paperProvider.build() 加载题目(mock 600ms 延迟)→ UI 显示 loading + └─ ExamPage.initState → _restoreProgress() + └─ AnswerRecordStore.findByPaper(1001) ← 查 Isar + └─ 有记录? + ├─ answerProvider.restore(saved) ← 注入内存 + └─ _tryJumpAfterRestore() ← 定位到最后作答的题 + └─ jumpToPage(index) (首帧后执行,等 PageView attach) +``` + +**为什么恢复和加载分开**:两个独立数据源(Isar / 接口),互不阻塞。题目加载失败也能恢复答案,反之也能做题。 + +**为什么定位要处理"谁先完成"**:restore(Isar 查询)和题目加载(600ms)完成顺序不定。双保险:restore 完成后立即尝试(`_tryJumpAfterRestore`),build 的 data 分支再兜底触发一次,`_jumpToQuestion` 置 null 保证只定位一次。 + +**为什么用 updatedAt 最新的一条定位**:重复作答会覆盖更新时间,天然表示"最后操作位置"(若希望始终定位最大题号,改为取 questionId 最大即可)。 + +### 4.2 答题(一次点击,内存和磁盘各走一条路) + +``` +用户点选项 "B" + → ChoiceQuestionView._onTap → onAnswer("B") ← 组件抛事件 + → ExamPage 回调 + → answerProvider.answer(1001, "B") + ① 内存:state = {...state, 1001: "B"} + → ref.watch 的页面自动重建(答题卡变蓝、进度条 +1) + ② 磁盘:AnswerRecordStore.put(...) ← unawaited,不阻塞 UI +``` + +### 4.3 退出 + +什么都不用做。答案在第 4.2 步已实时落盘(增量写),这就是"每次作答落盘"相对"交卷时统一保存"的优势:杀进程也不丢。 + +### 4.4 交卷(清理 + 判分) + +``` +点"交卷" → 确认弹窗(已答/未答统计) + → deleteByPaper(1001) ← 按 paperId 批量删 Isar 记录(防下次恢复旧答案) + → answerProvider.clear() ← 清内存 + → 判分:客观题比对 user == answer;解答题跳过(需人工批改) +``` + +**为什么交卷必须删记录**:否则下次进卷会恢复已交卷的答案。 + +--- + +## 五、关键设计决策(为什么这么弄) + +### 5.1 答案统一 String 格式 + +| 题型 | 存储格式 | +|---|---| +| 单选 | `"A"`(一个字母) | +| 多选 | `"A,C"`(字母升序逗号拼接) | +| 判断 | `"对"` / `"错"` | +| 解答 | 纯文本 | + +**为什么统一**:`Map` 一个结构通吃所有题型,provider / Isar / 交卷序列化不分叉;判分一行比对;加新题型不改上层。编码/解码工作收敛在 QuestionCard 内部。 + +### 5.2 PageView.builder 大数据性能 + +```dart +PageView.builder( + controller: _pageController, + itemCount: questions.length, // 100 / 1000 都没关系 + allowImplicitScrolling: true, // 预构建相邻页 → 滑动不掉帧 + itemBuilder: (context, i) => QuestionCard(key: ValueKey(q.questionId), ...), +) +``` + +1. **builder 惰性构建**:只构建视口内 ±1 页,滑出即回收(Vue swiper 需要虚拟列表插件才能做到,Flutter 天生自带) +2. **allowImplicitScrolling**:滑动动画开始前目标页已渲染,快速连滑无白屏 +3. **ValueKey(questionId)**:PageView 复用同一位置 Element,不带 key 换题时会显示旧内容 + +### 5.3 内存 vs 磁盘的分工 + +| 数据 | 位置 | 生命周期 | +|---|---|---| +| 题目列表 | Riverpod 内存 | 进卷拉取,退出即弃 | +| 答案主状态 | Riverpod 内存 | 进卷注入,交卷清空 | +| 答案持久化 | Isar AnswerRecord | 作答即写,交卷即删 | +| 题库/错题本 | Isar(既有集合) | 跨会话资产 | + +--- + +## 六、Vue3 → Flutter 概念对照 + +| Vue3 | Flutter | 本项目体现 | +|---|---|---| +| swiper 组件 | `PageView.builder` | 左右滑题,懒加载 | +| Pinia store | Riverpod provider | paperProvider / answerProvider | +| storeToRefs / computed | `ref.watch(provider)` | 页面响应式读取 | +| store action | `ref.read(provider.notifier).方法()` | answer() / restore() / clear() | +| 组件 props / emit | 构造参数 / 回调 | QuestionCard 的 answer / onAnswer | +| localStorage | Isar(或 shared_preferences) | AnswerRecord 续答落盘 | +| Promise + setTimeout mock | `const bool mock` + Future.delayed | PaperNotifier 的 mock 开关 | +| 组件内部 data | 不放组件,放 provider | PageView 会销毁页面 | + +--- + +## 七、踩坑记录(血泪教训) + +1. **业务代码别 import Isar 模型**:`*.g.dart` 的 64 位 schema 哈希让 dart2js 编译失败。页面只能通过 store 接口(dynamic)拿数据 +2. **状态别放组件里**:页面销毁就丢。判断标准:数据在页面滑走/关掉后还需要吗?需要就放 provider +3. **改 Isar 模型后必须重跑 build_runner**:`dart run build_runner build`(新版已移除 `--delete-conflicting-outputs` 参数) +4. **Riverpod 3.x 的 StateProvider 已移入 legacy**:用现代 `Notifier` / `NotifierProvider` 写法(本项目 PaperSizeNotifier 即范例) +5. **build_runner 重跑后 schema 哈希会变**:模型加字段后哈希变化是正常的,不要手改生成代码 + +--- + +## 八、扩展指南 + +### 加一种新题型(如"填空") + +1. `question_type.dart`:枚举加一项 `blank(5, '填空题')` +2. `question_card.dart`:switch 加一个 case,写 BlankQuestionView +3. `paper_provider.dart`:MockExamData 的循环里加一种分布 +4. 答案格式遵循 String 约定(填空存答案文本) + +上层(provider / store / 交卷)**一行不用改**——这就是统一 String 格式 + 纯展示组件设计带来的扩展性。 + +### 接入真实接口 + +1. `paper_provider.dart`:`mock = false`,build 里调 DioClient 拉题,`ExamQuestion.fromJson` 解析 +2. `answer_provider.dart`:`demoPaperId` 换成试卷对象传入的 paperId +3. 交卷处替换模拟提交为真实接口(注意:接口失败不要删本地记录) + +--- + +## 九、一句话总结 + +> **数据存 Isar(store 收口)、状态住 Riverpod(provider 分层)、组件纯展示(props 进回调出)、页面只桥接(ExamPage 唯一连接点)** —— 每一层只做一件事,数据永远单向流动。 + +这套四层模式是 Flutter 中型应用的通用骨架,后续任何新功能(错题本、收藏、播放记录)都按这个骨架填:建模型 → 写 store → 建 provider → 写组件 → 页面桥接。 diff --git a/docs/flutter_learning_journey.md b/docs/flutter_learning_journey.md new file mode 100644 index 0000000..8290dbb --- /dev/null +++ b/docs/flutter_learning_journey.md @@ -0,0 +1,712 @@ +# Flutter 学习全旅程讲解 — 从零到生产级项目 + +> **预计时长:40 分钟** | **项目:`new_learn`** | **Flutter 3.44.x + Dart 3.12** + +--- + +## 目录 + +| 章节 | 主题 | 预估时长 | +|------|------|----------| +| 一 | 环境安装与项目初始化 | ~4 min | +| 二 | Dart 3 语法核心要点 | ~4 min | +| 三 | Flutter 3 核心框架与分层架构 | ~5 min | +| 四 | 全局状态管理:Riverpod 方案 | ~4 min | +| 五 | Dio 网络请求生产级封装 | ~4 min | +| 六 | 屏幕适配:ScreenUtil 方案 | ~3 min | +| 七 | 多品牌主题配置体系 | ~3 min | +| 八 | 多环境变量配置体系 | ~3 min | +| 九 | 本地存储:Isar + 响应式 watch | ~3 min | +| 十 | Demo 组件封装思路汇总 | ~5 min | +| 十一 | 学习路径与心得总结 | ~2 min | + +--- + +## 一、环境安装与项目初始化(~4 min) + +### 1.1 工具链一览 + +| 工具 | 版本 | 用途 | +|------|------|------| +| Flutter SDK | 3.44.x | 编译、热重载、多端构建 | +| Dart SDK | 3.12 | 语言运行时 | +| Android Studio / VS Code | 最新 | IDE + 插件 | +| Android SDK | API 34+ | 安卓构建 | +| Xcode | 15+(Mac) | iOS 构建 | + +> 本项目路径:`D:\dev\flutter_3.44.8\flutter`(见 [local.properties](file:///d:/flutter_learn/new_learn/android/local.properties)) + +### 1.2 创建项目 + +```bash +flutter create --org com.example --platforms=android,ios,web,windows new_learn +``` + +### 1.3 项目核心依赖 + +完整依赖见 [pubspec.yaml](file:///d:/flutter_learn/new_learn/pubspec.yaml),关键项: + +```yaml +environment: + sdk: ^3.12.2 + +dependencies: + flutter: { sdk: flutter } + dio: ^5.10.0 # 网络请求 + go_router: ^17.4.0 # 声明式路由 + flutter_riverpod: ^3.3.2 # 状态管理 + flutter_screenutil: ^5.9.0 # 屏幕适配 + + cached_network_image: ^3.4.1 # 网络图片缓存(ImageDemo) + infinite_scroll_pagination: ^5.1.1 # 分页列表 + flutter_widget_from_html_core: ^0.17.2 # 富文本 HTML 渲染 + + shared_preferences: ^2.5.3 # 轻量本地存储 + flutter_secure_storage: ^9.2.4 # 敏感凭据安全存储 + isar_community: ^3.3.2 # 本地数据库 + isar_community_flutter_libs: ^3.3.2 + path_provider: ^2.1.5 + + wechat_assets_picker: ^9.8.0 # 相册多选/预览(原生端) + wechat_camera_picker: ^4.6.0 # 微信风格相机(原生端) + image_picker: ^1.2.3 # Web 兜底图片选择 + permission_handler: ^11.3.1 # 权限管理 + + image: ^4.8.0 # 纯 Dart 图片处理(含 Web) + flutter_image_compress: ^2.5.1 # 原生压缩(Android/iOS) + photo_view: ^0.15.0 # 图片预览缩放 + + video_player: ^2.13.0 # 视频播放 + chewie: ^1.13.1 # 视频 UI 封装 + just_audio: ^0.10.6 # 音频播放 + + share_plus: ^10.1.4 # 系统分享 + gal: ^2.3.3 # 保存到相册 + package_info_plus: ^8.3.0 # 版本信息 + url_launcher: ^6.3.1 # 外部跳转 + smooth_sheets: ^1.1.1 # 常驻底部面板 + + crypto: ^3.0.6 # MD5 等加密 + # 完整依赖与注释说明以 pubspec.yaml 为准(此处为学习旅程的“关键依赖速览”) +``` + +### 1.4 目录结构 + +``` +lib/ +├── main.dart # 启动入口(5步初始化) +├── core/ # 核心基础设施层 +│ ├── app.dart # 根组件 TabApp +│ ├── config/ # 环境+品牌配置 +│ ├── network/ # Dio 封装 + API +│ ├── providers/ # Riverpod 全局状态 +│ ├── router/ # go_router 路由表 +│ ├── theme/ # 多品牌主题 +│ ├── storage/ # Isar + 安全存储 +│ ├── services/ # 图片/选择/分享服务 +│ └── components/ # 9个可复用组件 +├── features/ # 业务功能层 +│ ├── auth/ # 登录+隐私协议 +│ ├── exam/ # 试卷练习 +│ ├── my/ # 个人中心 +│ └── ... +└── pages/demo/ # 28个独立学习Demo +``` + +> **讲解要点**:这套 `core / features / pages` 三层分离,对标前端 `utils / views / components`,让基础设施和业务逻辑彻底解耦。 + +--- + +## 二、Dart 3 语法核心要点(~4 min) + +### 2.1 与 JavaScript/TypeScript 的关键差异 + +| 概念 | JS/TS | Dart 3 | +|------|-------|--------| +| 类型声明 | `let x: number` | `int x` / `var x`(类型推断)| +| 空安全 | 可选 `?` | 健全空安全 `String?`,编译期拦截 | +| 异步 | Promise | `Future` + `async/await` | +| 集合 | Array/Map | `List` / `Map` / `Set` | +| 类 | class | class + `mixin` 混入 | +| 模式匹配 | 无 | `switch` 表达式 + 解构(Dart 3 新特性)| + +### 2.2 空安全(Sound Null Safety) + +```dart +String name = 'Flutter'; // 不可为 null +String? nickname; // 可为 null,使用前必须判空 + +// 安全调用 +int len = nickname?.length ?? 0; // ?. + ?? 兜底 +``` + +> **核心规则**:所有变量默认非空,`?` 才允许 null。编译期就帮你拦住空指针。 + +### 2.3 异步编程 +`fromJson` 是构造函数,把字典转换成我们定义的 User 实体类对象,获得类型安全、点访问、编译检查。 + +```dart +Future fetchUser() async { + final response = await dio.get('/user'); + return User.fromJson(response.data); +} +``` + +### 2.4 模式匹配(Dart 3 特性) + +```dart +// switch 表达式 +String describe(Object obj) => switch (obj) { + int() => '整数', + String() => '字符串', + List() => '整数列表', + _ => '未知', +}; +``` + +### 2.5 类与混入 + +```dart +mixin Disposable { + void dispose(); +} + +class MyController with Disposable { + @override + void dispose() { /* 释放资源 */ } +} +``` + +> **讲解要点**:Dart 3 的空安全是「健全」的,编译期保证非空变量不会拿到 null,比 TS 的可选链更彻底。模式匹配让分支逻辑更简洁。 + +--- + +## 三、Flutter 3 核心框架与分层架构(~5 min) + +### 3.1 Widget 是什么? + +**核心认知**:Widget 是「配置对象」不是「渲染 View」。 + +``` +Widget(配置蓝图)→ Element(实例化树节点)→ RenderObject(真正测量布局绘制) +``` + +- `StatelessWidget`:纯展示,props 不变 → 如 `Text`、`Icon` +- `StatefulWidget`:有内部状态,`setState` 触发重建 → 如 `Checkbox`、`TextField` + +### 3.2 核心布局原理 + +> **Flutter 布局第一法则**:父组件向子组件传递**约束(Constraints)**,子组件根据约束**决定自身大小(Size)**,然后向上返回。 + +``` +父 Widget ──Constraints──> 子 Widget + ↓ 决定自身大小 +父 Widget <──Size────────── 子 Widget +``` + +- **主轴**(MainAxis):Row 的水平方向 / Column 的垂直方向 +- **交叉轴**(CrossAxis):与主轴垂直的方向 + +### 3.3 启动流程链路 + +入口文件 [main.dart](file:///d:/flutter_learn/new_learn/lib/main.dart): + +``` +main() 5步串行初始化 + ↓ +1. WidgetsFlutterBinding.ensureInitialized() // 插件通道就绪 +2. GoRouter.optionURLReflectsImperativeAPIs // 修复 web 地址栏同步 +3. DioClient.instance.restoreToken() // 回填登录 Token +4. privacyAcceptedNotifier // 恢复隐私协议状态 +5. AppDatabase.instance.init() // 初始化 Isar + ↓ +runApp(ProviderScope(child: TabApp())) // Riverpod 容器 + 根组件 +``` + +根组件 [app.dart](file:///d:/flutter_learn/new_learn/lib/core/app.dart): + +``` +TabApp (StatelessWidget) + └─ ScreenUtilInit // 屏幕适配 + └─ MaterialApp.router // Material3 + 品牌种子色 + ├─ builder: PrivacyStartupGate // 启动层隐私拦截 + └─ routerConfig: appRouter // 全局路由表 +``` + +### 3.4 路由体系 + +路由表 [app_router.dart](file:///d:/flutter_learn/new_learn/lib/core/router/app_router.dart): + +``` +GoRouter + ├─ redirect: _guardLogin // 登录守卫(对标 vue-router beforeEach) + │ ├─ 未同意隐私 → /privacy-consent + │ ├─ 已同意+在隐私页 → /login 或 / + │ ├─ 未登录 → /login + │ └─ 已登录+在登录页 → / + ├─ refreshListenable: authTick + privacyNotifier // 响应式守卫 + ├─ ShellRoute (4 tab 壳) + │ ├─ / → TaskPage + │ ├─ /recite → RecitePage + │ ├─ /class → ClassPage + │ └─ /my → MyPage + ├─ /privacy-consent + ├─ /login + └─ /myPoint +``` + +> **讲解要点**:go_router 的 `refreshListenable` 是核心 — 登录态变化时自动 `refresh()` 重算 redirect,不需要手动跳转,对标 Vue Router 的响应式 `beforeEach`。 + +--- + +## 四、全局状态管理:Riverpod 方案(~4 min) + +### 4.1 为什么选 Riverpod? + +| 维度 | Provider(旧) | Riverpod 3.x | +|------|----------------|--------------| +| 依赖注入 | `context.read()` 耦合 BuildContext | 全局容器,不依赖 context | +| 测试 | 难 mock | `ProviderContainer` 轻量覆盖 | +| 安全 | 运行时报错 | 编译期检查(Provider 不可缺)| +| 异步 | 手写 FutureBuilder | AsyncNotifier 内建 loading/error/data | + +### 4.2 本项目的两种状态方案 + +#### 方案 A:ValueNotifier — 轻量全局信号 + +隐私协议状态 [privacy_provider.dart](file:///d:/flutter_learn/new_learn/lib/core/providers/privacy_provider.dart): + +```dart +final ValueNotifier privacyAcceptedNotifier = ValueNotifier(false); +``` + +> **为什么不用 Riverpod?** go_router 的 `refreshListenable` 需要 `Listenable`,ValueNotifier 足够轻量,且 `main()` 中 `runApp` 前就能直接恢复状态。 + +#### 方案 B:Riverpod AsyncNotifier — 全局用户状态 + +用户状态 [user_provider.dart](file:///d:/flutter_learn/new_learn/lib/core/providers/user_provider.dart): + +```dart +class CurrentUserNotifier extends AsyncNotifier { + @override + Future build() async { + // 冷启动异步从本地缓存回填 + return await UserStorage.instance.readUser(); + } + + void set(user) { + state = AsyncData(user); // 有值 + authTick.value++; // 触发路由守卫重算 + } +} + +// 全局 Provider +final currentUserProvider = + AsyncNotifierProvider(...); +``` + +状态四态生命周期: + +``` +AsyncLoading() → 正在读缓存 +AsyncData(user) → 有值(已登录) +AsyncData(null) → 明确未登录 +AsyncError(e) → 异常 +``` + +> **讲解要点**:`authTick`(ValueNotifier)是登录态变化的信号源,Riverpod 写状态、ValueNotifier 通知路由,两者协作实现「响应式登录守卫」。 + +--- + +## 五、Dio 网络请求生产级封装(~4 min) + +### 5.1 封装架构 + +核心文件 [dio_client.dart](file:///d:/flutter_learn/new_learn/lib/core/network/dio_client.dart): + +``` +DioClient (单例) + ├─ BaseOptions + │ ├─ baseUrl → 来自 AppConfig(品牌×环境组合) + │ └─ 超时:连接 15s / 接收 15s / 发送 15s + ├─ 拦截器三件套 + │ ├─ onRequest → 自动加 Authorization: Bearer token + │ ├─ onResponse → 日志打印 + │ └─ onError → 401清Token + DioException→ApiException + ├─ 泛型请求方法 + │ ├─ get(path, {cancelToken, queryParameters}) + │ ├─ post(path, {data, cancelToken, onSendProgress}) + │ └─ put(path, {data}) + └─ restoreToken() → 冷启动回填 Token +``` + +### 5.2 统一异常翻译 + +```dart +// UI 层只需要 catch ApiException,不用管 DioException +ApiException _mapError(DioException e) { + switch (e.type) { + case DioExceptionType.connectionTimeout: + return ApiException('网络连接超时,请检查网络'); + case DioExceptionType.connectionError: + return ApiException('网络连接失败,请检查网络'); + // ... badResponse / cancel / unknown + } +} +``` + +### 5.3 生产级能力(Demo 演示) + +| 能力 | 实现方式 | Demo | +|------|----------|------| +| 下载进度 | `onReceiveProgress` 回调 | [dio_pro_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/dio_pro_demo.dart) | +| 取消请求 | `CancelToken` + dispose 自动取消 | 同上 | +| 失败重试 | 指数退避 `_canRetry`(仅网络层错误)| 同上 | +| Token 自动注入 | 拦截器 onRequest | dio_client.dart | +| 401 自动登出 | 拦截器 onError | dio_client.dart | +| OSS 直传 | 已有 authorization 头时跳过注入 | [oss_uploader.dart](file:///d:/flutter_learn/new_learn/lib/core/network/oss_uploader.dart) | + +### 5.4 数据模型 fromJson + +```dart +// user_model.dart — 防御性解析,兼容后端返回字符串数字 +factory CurrentUserExtension.fromJson(Map json) { + return CurrentUserExtension( + userId: json['userId'] as int, + userNo: json['userNo']?.toString() ?? '', + level: _toInt(json['level']), // 兼容 "1" 或 1 + balance: _toDouble(json['balance']), + // ... 17 个字段 + ); +} +``` + +> **讲解要点**:`fromJson` 用 `_toInt` / `_toDouble` 兜底,是真实项目中最常见的后端类型不稳定问题。UI 层永远只 catch `ApiException` 一个类型。 + +--- + +## 六、屏幕适配:ScreenUtil 方案(~3 min) + +### 6.1 核心文件 + +适配模板 [screen_adapt_template.dart](file:///d:/flutter_learn/new_learn/lib/core/utils/screen_adapt_template.dart): + +``` +AppAdapt 工具类 + ├─ tabletBreakpoint = 600 // Material 平板断点 + ├─ phoneDesign = Size(375, 812) // iPhone 逻辑尺寸 + ├─ designSizeOf(context) // 方案B:平板用设备自身尺寸 + └─ fontSizeResolver // 方案A:字体放大封顶 1.2 倍 +``` + +### 6.2 双方案策略 + +| 方案 | 目标 | 实现 | +|------|------|------| +| 方案 A | 大屏字体不过分放大 | `fontSizeResolver` 封顶 `maxFontScale=1.2` | +| 方案 B | 平板布局不被拉伸 | 宽高都 ≥600dp 时用设备自身尺寸做 designSize,缩放回到 ~1 | + +### 6.3 使用规则 + +```dart +// 字号:用 .spMin(大屏只缩不放) +Text('你好', style: TextStyle(fontSize: 16.spMin)); + +// 宽度/横向间距:用 .w +SizedBox(width: 16.w); + +// 高度/纵向:用 .h +SizedBox(height: 48.h); + +// 圆角:用 .r +BorderRadius.circular(8.r); +``` + +> **讲解要点**:字号必须用 `.spMin` 而非 `.sp` — `.sp` 在大屏会把字体放得过大,`.spMin` 确保只缩小不放大。 + +--- + +## 七、多品牌主题配置体系(~3 min) + +### 7.1 三层解耦设计 + +``` +环境(接口在哪) → app_env.dart 编译期 --dart-define 定死 +品牌(谁家 App) → app_brand.dart 发布期定死,7个品牌 +主题(长什么样) → brand_theme.dart 运行时可切换 +配置聚合 → app_config.dart 全项目唯一取配置入口 +``` + +### 7.2 两级主题模型 + +模型文件 [brand_theme.dart](file:///d:/flutter_learn/new_learn/lib/core/theme/brand_theme.dart): + +``` +BrandTheme(一级品牌) + ├─ name: 'SkyBlue' + ├─ seedColor: Color(0xFF4FC3F7) // 种子色 → Material3 自动生成整套色板 + └─ pages: Map + ├─ home: PageTheme { bg, headerBg, accent, title, banner } + ├─ detail: PageTheme { ... } + └─ result: PageTheme { ... } +``` + +品牌主题文件: +- [sky_blue_theme.dart](file:///d:/flutter_learn/new_learn/lib/core/theme/brands/sky_blue_theme.dart) — seedColor `0xFF4FC3F7` +- [red_theme.dart](file:///d:/flutter_learn/new_learn/lib/core/theme/brands/red_theme.dart) — seedColor `0xFFEF4444` + +### 7.3 新增主题三步走 + +``` +1. 在 brands/ 下新建 xxx_theme.dart → 定义 seedColor + 三层级配置 +2. 在 brand_theme.dart 中 import + 添加到 brandThemes 列表 +3. 在 app_brand.dart 中给品牌指定 themeId +``` + +> **讲解要点**:「模型文件不写颜色、品牌文件不写逻辑」— 新增主题零改页面代码。`colorSchemeSeed` 让 Material 3 自动从种子色生成完整配色。 + +--- + +## 八、多环境变量配置体系(~3 min) + +### 8.1 编译期注入 + +环境配置 [app_env.dart](file:///d:/flutter_learn/new_learn/lib/core/config/app_env.dart): + +```dart +// 编译期注入,默认 prod +static const String env = String.fromEnvironment('ENV', defaultValue: 'prod'); +static const String brand = String.fromEnvironment('BRAND', defaultValue: 'aixue'); +``` + +### 8.2 三套环境地址 + +| 环境 | baseUrl | 用途 | +|------|---------|------| +| dev | `k12-student-api-test.23544.com:16400` | 本地开发联调 | +| test | `axdb-test.cnzhyx.cn` | 测试环境 | +| prod | `ylax-api.cqbzyx.com`(被品牌专属地址覆盖)| 生产 | + +### 8.3 运行命令 + +```bash +# 开发环境 + 爱学蝶变品牌 +flutter run --dart-define=ENV=dev --dart-define=BRAND=aixue + +# 生产环境 + 楚才品牌 +flutter run --dart-define=ENV=prod --dart-define=BRAND=chucai + +# 构建 APK +flutter build apk --dart-define=ENV=prod --dart-define=BRAND=chucai +``` + +### 8.4 配置聚合层 + +[app_config.dart](file:///d:/flutter_learn/new_learn/lib/core/config/app_config.dart) — 全项目唯一取配置入口: + +```dart +// baseUrl 规则:prod 用品牌专属地址覆盖,否则环境通用地址 +static String get baseUrl => + AppEnv.isProd ? (currentBrand.baseUrl ?? AppEnv.baseUrl) : AppEnv.baseUrl; + +// 主题:按品牌查主题注册表,未知兜底第一个 +static BrandTheme get theme => + brandThemes.firstWhere((t) => t.name == currentBrand.themeId, + orElse: () => brandThemes.first); +``` + +> **讲解要点**:`String.fromEnvironment` 是编译期常量,不占运行时开销。品牌 × 环境组合让一套代码出 7 个品牌的 App,只需改运行命令。 + +--- + +## 九、本地存储:Isar + 响应式 watch(~3 min) + +### 9.1 存储组合 + +| 存储方式 | 用途 | 文件 | +|----------|------|------| +| `flutter_secure_storage` | Token 安全存储 | [user_storage.dart](file:///d:/flutter_learn/new_learn/lib/core/storage/user_storage.dart) | +| `shared_preferences` | 隐私协议同意状态 | [privacy_storage.dart](file:///d:/flutter_learn/new_learn/lib/core/storage/privacy_storage.dart) | +| `isar_community` | 题库/错题/播放记录 | [app_database.dart](file:///d:/flutter_learn/new_learn/lib/core/storage/database/app_database.dart) | + +### 9.2 Isar 数据模型 + +```dart +@collection +class WrongQuestion { + Id id = Isar.autoIncrement; + late int questionId; + late int userId; + late String questionTitle; + late DateTime createdAt; +} +``` + +> 通过 `build_runner` 生成 `.g.dart`,提供类型安全的 CRUD。 + +### 9.3 平台分流实现 + +``` +app_database.dart → 抽象接口 + ├─ app_database_io.dart → 原生平台 Isar 实现 + └─ app_database_web.dart → Web 调试自动跳过(Isar 不支持 Web) +``` + +### 9.4 watch 响应式 — 错题自动刷新 + +Demo 演示 [isar_watch_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/isar_watch_demo.dart): + +```dart +// Isar watch:数据变化自动通知 UI 刷新 +Stream> watchWrongQuestions() { + return database.wrongQuestions + .where() + .userIdEqualTo(userId) + .watch(fireImmediately: true); +} +``` + +> **讲解要点**:Isar 的 `watch()` 是真正的响应式 — 数据库写入后自动通知 Stream 监听者,UI 无需手动刷新。对标前端的「数据驱动视图」,但是是本地数据库级别的。 + +--- + +## 十、Demo 组件封装思路汇总(~5 min) + +### 10.1 28 个 Demo 的分类学习路径 + +#### 第一阶段:基础组件 + +| Demo | 核心知识点 | 代码 | +|------|-----------|------| +| Text | 样式/截断/富文本/手势 | [text_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/text_demo.dart) | +| Image | 四种来源 + BoxFit + 错误兜底 | [image_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/image_demo.dart) | +| Input | controller + dispose + 密码显隐 | [input_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/input_demo.dart) | +| Align | 对齐 + Riverpod 全局信息 | [align_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/align_demo.dart) | +| Stack | 层叠 + 全 Positioned 尺寸坑 | [stack_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/stack_demo.dart) | +| Wrap | 自动换行 + Chip + 自绘 CustomTag | [wrap_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/wrap_demo.dart) | + +#### 第二阶段:布局进阶 + +| Demo | 核心知识点 | 代码 | +|------|-----------|------| +| List | 懒加载/下拉刷新/触底加载 | [list_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/list_demo.dart) | +| Grid | count/builder/extent 三种构造 | [grid_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/grid_demo.dart) | +| Sliver | CustomScrollView + 折叠 AppBar | [sliver_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/sliver_demo.dart) | +| Responsive | 手机单列 vs Pad 多列 | [responsive_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/responsive_demo.dart) | + +#### 第三阶段:路由与表单 + +| Demo | 核心知识点 | 代码 | +|------|-----------|------| +| Router | 命名路由表 + 三种跳转姿势 | [router_home_page.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/router_home_page.dart) | +| Detail | 构造传参 vs 命名路由 arguments | [detail_page.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/detail_page.dart) | +| Form | 7 个校验知识点/两段式提交 | [form_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/form_demo.dart) | +| Picker | pop 带值返回 | [picker_page.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/picker_page.dart) | + +#### 第四阶段:网络与数据 + +| Demo | 核心知识点 | 代码 | +|------|-----------|------| +| Dio | GET/POST/错误兜底三态 UI | [dio_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/dio_demo.dart) | +| Dio Pro | 下载进度/CancelToken/重试 | [dio_pro_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/dio_pro_demo.dart) | +| Isar Watch | 错题本自动刷新 | [isar_watch_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/isar_watch_demo.dart) | +| Wrong Flow | 接口+Isar+watch 完整链路 | [wrong_flow_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/wrong_flow_demo.dart) | +| OSS Upload | 客户端签名+PUT直传 | [oss_upload_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/oss_upload_demo.dart) | + +#### 第五阶段:主题与媒体 + +| Demo | 核心知识点 | 代码 | +|------|-----------|------| +| Theme | 两级主题模型 + 品牌换肤 | [theme_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/theme_demo.dart) | +| Seed Palette | colorSchemeSeed 自动生成配色 | [seed_palette_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/seed_palette_demo.dart) | +| Animation | 隐式/显式/Hero/CustomPaint | [animation_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/animation_demo.dart) | +| Rich Text | HTML 题目渲染 + audio 替换 | [rich_text_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/rich_text_demo.dart) | +| Audio | just_audio 控制条 | [audio_player_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/audio_player_demo.dart) | +| Share | 文本/图片/截图海报/存相册 | [share_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/share_demo.dart) | +| Smooth Sheet | 常驻多停靠底部面板 | [smooth_sheet_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/smooth_sheet_demo.dart) | + +### 10.2 核心组件封装思路(9个可复用组件) + +| 组件 | 封装思路 | 代码 | +|------|----------|------| +| **PrivacyStartupGate** | 启动层隐私 Gate — 冷启动不闪登录页,视觉第一拦 | [privacy_startup_gate.dart](file:///d:/flutter_learn/new_learn/lib/core/components/privacy_startup_gate.dart) | +| **CommonPagedList** | 通用分页列表 — 基于 infinite_scroll_pagination,对标 z-paging | [common_paged_list.dart](file:///d:/flutter_learn/new_learn/lib/core/components/common_paged_list.dart) | +| **AudioPlayerCard** | 通用音频控制条 — 播放/进度/倍速/循环,切后台自动暂停 | [audio_player_card.dart](file:///d:/flutter_learn/new_learn/lib/core/components/audio_player_card.dart) | +| **VideoPlayerView** | 通用视频播放器内嵌版 — 无 Scaffold,可嵌入任意位置 | [video_player_view.dart](file:///d:/flutter_learn/new_learn/lib/core/components/video_player_view.dart) | +| **ImagePreviewViewer** | 通用图片预览 — photo_view 双指缩放 + Hero 动画 | [image_preview_viewer.dart](file:///d:/flutter_learn/new_learn/lib/core/components/image_preview_viewer.dart) | +| **RichTextContent** | 富文本渲染 — HTML + audio/video 自动替换为播放器 | [rich_text_content.dart](file:///d:/flutter_learn/new_learn/lib/core/components/rich_text_content.dart) | +| **PersistentSnapSheet** | 常驻底部面板 — smooth_sheets 封装 | [persistent_snap_sheet.dart](file:///d:/flutter_learn/new_learn/lib/core/components/persistent_snap_sheet.dart) | +| **AppUpdateDialog** | 版本更新弹窗 — 强制/可选 + 平台分流跳转 | [app_update_dialog.dart](file:///d:/flutter_learn/new_learn/lib/core/components/app_update_dialog.dart) | + +### 10.3 服务层封装思路(3个核心服务) + +| 服务 | 封装思路 | 代码 | +|------|----------|------| +| **PickerService** | 图片选择统一服务 — Web用image_picker / 原生用微信选择器 + 权限守卫 | [picker_service.dart](file:///d:/flutter_learn/new_learn/lib/core/services/picker_service.dart) | +| **ImageProcessor** | 图片压缩 — 原生走flutter_image_compress / Web走纯Dart image包(Isolate隔离)| [image_processor.dart](file:///d:/flutter_learn/new_learn/lib/core/services/image_processor.dart) | +| **ShareService** | 分享服务 — 截图Widget渲染树→PNG→分享/存相册 | [share_service.dart](file:///d:/flutter_learn/new_learn/lib/core/services/share_service.dart) | + +### 10.4 封装设计原则 + +1. **平台分流**:同一接口,io/web 双实现,条件 import自动选择 +2. **资源释放**:所有 `TextEditingController` / `StreamSubscription` / `CancelToken` / `AnimationController` 在 `dispose` 中销毁 +3. **防御性解析**:`fromJson` 兜底后端类型不稳定(`_toInt` / `_toDouble`) +4. **单一职责**:组件只管渲染,数据逻辑下沉到 Provider/Service +5. **配置驱动**:环境/品牌/主题互不依赖,全项目统一走 `AppConfig` 取配置 + +--- + +## 十一、学习路径与心得总结(~2 min) + +### 11.1 推荐学习顺序 + +``` +第一阶段:Widget 基础 + Text → Image → Input → Align → Stack → Wrap + ↓ +第二阶段:布局与列表 + List → Grid → Sliver → Responsive + ↓ +第三阶段:路由与表单 + Router → Detail → Form → Picker + ↓ +第四阶段:网络与数据 + Dio → Dio Pro → Isar Watch → Wrong Flow → OSS Upload + ↓ +第五阶段:主题与媒体 + Theme → Seed Palette → Animation → Rich Text → Audio → Share → Smooth Sheet +``` + +### 11.2 从前端转 Flutter 的核心思维转变 + +| 前端思维 | Flutter 思维 | +|----------|-------------| +| DOM 是真实节点 | Widget 是不可变配置,每帧重建 | +| 数据驱动视图(Vue/React)| setState 触发 Element 树 diff + 重建 | +| CSS 盒模型 | 约束传递模型(父给约束,子定大小)| +| 路由守卫 beforeEach | GoRouter redirect + refreshListenable | +| Pinia/Vuex 全局 store | Riverpod Provider + AsyncNotifier | +| axios 拦截器 | Dio 拦截器三件套 | +| rem/vw 适配 | ScreenUtil .w/.h/.spMin | +| 多环境 .env 文件 | --dart-define 编译期注入 | + +### 11.3 本项目的技术亮点 + +1. **一套代码出 7 个品牌 App** — 编译期 `--dart-define` 注入品牌+环境 +2. **响应式登录守卫** — Riverpod 写状态 + ValueNotifier 通知路由 +3. **双方案屏幕适配** — 手机用 375 设计稿 + 平板回退原始尺寸 + 字体封顶 +4. **两级主题模型** — 种子色自动生成色板 + 页面细节配置 +5. **生产级 Dio 封装** — 拦截器/统一异常/重试/取消/进度 +6. **OSS 客户端直传** — V4 签名 + PUT 直传 + 进度 +7. **Isar 响应式数据库** — watch 驱动 UI 自动刷新 +8. **28 个 Demo 全覆盖** — 从 Text 到 OSS,每个都有配套文档 + +### 11.4 配套文档 + +- Demo 文档索引:[docs/demos/README.md](file:///d:/flutter_learn/new_learn/docs/demos/README.md) +- 试卷架构设计:[docs/exam_architecture.md](file:///d:/flutter_learn/new_learn/docs/exam_architecture.md) +- 每个 Demo 都有六章结构文档:问题背景 / 运行方式 / 核心知识点 / 关键代码剖析 / 踩坑记录 / 扩展思考 + +--- + +> **结语**:Flutter 的学习不是背 API,而是理解「Widget 是配置、Element 是实例化、RenderObject 是渲染」这条主线。所有的高级用法 — 状态管理、路由守卫、主题换肤 — 都是建立在这条主线上的。本项目 28 个 Demo 覆盖了从基础组件到生产级封装的完整路径,建议大家按顺序跑一遍,每个 Demo 都有配套文档。 diff --git a/integration_test/app_smoke_test.dart b/integration_test/app_smoke_test.dart new file mode 100644 index 0000000..65eb178 --- /dev/null +++ b/integration_test/app_smoke_test.dart @@ -0,0 +1,36 @@ +// 集成测试:真机/模拟器上跑完整 App 冒烟 +// +// 与 flutter test 的区别: +// 单元/Widget 测试跑在 Dart 虚拟机里(无原生插件,靠 mock); +// 集成测试跑在【真实设备】上,插件/网络/文件系统都是真的, +// 专门验证"代码 + 原生环境"组合起来能不能正常跑。 +// +// 运行(需要先插手机或开模拟器): +// flutter test integration_test -d +// 查看设备:flutter devices +// +// 注意:不经过 main(),所以 Token 不会从磁盘回填(DioClient.accessToken +// 初始为 null)→ go_router 守卫把 / 踢到 /login,首帧是登录页。 +// 这就是"真机启动 → 登录守卫"的端到端验证。 + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:new_learn/core/app.dart'; + +void main() { + // 集成测试必须最先调用(把测试绑定到真实设备渲染环境) + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('真机冒烟:App 启动 → 未登录重定向到登录页', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: TabApp())); + // 真机上等守卫 redirect + 首帧渲染完成 + await tester.pumpAndSettle(); + + expect(find.text('立即登录'), findsOneWidget); + expect(find.text('爱学蝶变'), findsOneWidget); + // 未登录不该看到主框架 tab + expect(find.text('任务'), findsNothing); + }); +} 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/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4aa106e --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*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 */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 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 = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; 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 = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 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; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + 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; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + 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.yuanxuan.aixuediebian; + 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.yuanxuan.aixuediebian.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.yuanxuan.aixuediebian.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.yuanxuan.aixuediebian.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.yuanxuan.aixuediebian; + 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.yuanxuan.aixuediebian; + 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 */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency 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..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..0a7c263 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,78 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + NSPhotoLibraryUsageDescription + 需要访问相册以选择笔记图片和头像 + NSPhotoLibraryAddUsageDescription + 需要保存攻克海报和截图到相册 + NSCameraUsageDescription + 需要访问相机以拍照上传笔记和头像 + NSMicrophoneUsageDescription + 需要访问麦克风以录制视频 + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + New Learn + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + new_learn + 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/core/api/auth/api.dart b/lib/core/api/auth/api.dart new file mode 100644 index 0000000..38d0108 --- /dev/null +++ b/lib/core/api/auth/api.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:new_learn/core/api/auth/model.dart'; +import 'package:new_learn/core/models/user_model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; +import 'package:new_learn/core/storage/user_storage.dart'; + +/// ============================================================ +/// 【UserApi】用户相关接口(对齐 Vue 端 api/user/index.ts) +/// +/// 分层职责(像 Vue 项目把接口收敛到 api 目录一样): +/// Page(UI) → UserApi(业务方法) → DioClient(Dio 封装) → 服务器 +/// +/// 一、已实现: +/// login → POST /api/v1/User/Login +/// getCurrentUserExtension → GET /api/v1/User/GetCurrentUserExtension +/// (登录后拿完整用户信息:昵称/头像/班级/等级等) +/// getCurrentUser → 统一入口(本地缓存优先) +/// updateUserAvatar → 更改用户头像 +/// +/// 二、后续继续写(对齐 Vue,用到再加): +/// checkStudyPackStatus / setChangeUserOnline +/// getUserPointLevel / UpdateGuideStatus / UpdateReportStatus +/// +/// 三、高频坑: +/// 1. 登录密码 MD5 加密(对齐 Vue stringToMd5),提交前在 Api 层完成; +/// 2. getCurrentUserExtension 必须带 Token 才有效 —— +/// 登录成功已把 Token 写进 DioClient.accessToken,自动带上; +/// 3. 不要在这里 catch ApiException —— 交给页面统一处理展示。 +/// ============================================================ +class UserApi { + // ==================== 登录 ==================== + + /// 登录 → POST /api/v1/User/Login + /// 密码规则对齐 Vue 端 stringToMd5:MD5 加密后再提交 + static Future login({ + required String account, + required String password, + }) async { + final request = LoginRequest( + account: account.trim(), + password: _md5(password.trim()), + ); + final data = await DioClient.instance.post( + '/api/v1/User/Login', + data: request.toJson(), + ); + return LoginResponse.fromJson(data as Map); + } + + /// 登录后拉取完整用户信息 → GET /api/v1/User/GetCurrentUserExtension + static Future getCurrentUserExtension() async { + final data = await DioClient.instance.get( + '/api/v1/User/GetCurrentUserExtension', + ); + return CurrentUserExtension.fromJson(data as Map); + } + + /// 获取当前用户(统一入口,缓存优先): + /// 1. 有本地缓存 → 直接返回(不费流量不转圈); + /// 2. 无缓存或 forceRefresh → 调 GetCurrentUserExtension 拉最新 + /// (Token 由 Dio 拦截器自动带进 header)并写回缓存; + /// 3. 接口失败 → 回退返回本地缓存,缓存也没有才抛异常。 + /// 页面展示用户信息一律走这里,别直接调 getCurrentUserExtension。 + static Future getCurrentUser({ + bool forceRefresh = false, + }) async { + final cached = await UserStorage.instance.readUserInfo(); + if (!forceRefresh && cached != null) return cached; + + try { + final ext = await getCurrentUserExtension(); + await UserStorage.instance.saveUserInfo(ext); + return ext; + } catch (_) { + if (cached != null) return cached; // 网络失败回退本地缓存 + rethrow; + } + } + + /// 更改用户头像 → POST /api/v1/User/UpdateUserAvatar + /// + /// 对齐 UniApp:头像先传 OSS,拿到 url 后再把 {avatar: url} 提交给业务接口。 + static Future updateUserAvatar(String avatar) async { + await DioClient.instance.post( + '/api/v1/User/UpdateUserAvatar', + data: {'avatar': avatar}, + ); + } + + /// MD5 加密(UniApp stringToMd5 的等价实现) + static String _md5(String text) { + return md5.convert(utf8.encode(text)).toString(); + } +} diff --git a/lib/core/api/auth/model.dart b/lib/core/api/auth/model.dart new file mode 100644 index 0000000..dc593e6 --- /dev/null +++ b/lib/core/api/auth/model.dart @@ -0,0 +1,85 @@ +/// ============================================================ +/// 【auth 模块模型】登录相关实体 —— 对照 Vue 项目 src/api/login.ts +/// +/// LoginRequest → 登录请求体(toJson 提交给服务器) +/// LoginResponse → 登录响应(token + 用户信息) +/// UserInfo → 用户信息子模型(info 字段) +/// +/// 高频坑: +/// 1. fromJson 全部用 ?? 兜底,字段缺失/类型漂移不会闪崩; +/// 2. 数字用 (json['x'] as num?)?.toInt() 防 int/double 不一致; +/// 3. 兼容业务响应壳 {code, msg, data: {token, info}}。 +/// ============================================================ +library; + +class LoginRequest { + const LoginRequest({required this.account, required this.password}); + + final String account; + final String password; + + Map toJson() { + return { + 'account': account, + 'password': password, + }; + } +} + +class LoginResponse { + const LoginResponse({required this.token, required this.info}); + + final String token; + final UserInfo info; + + factory LoginResponse.fromJson(Map json) { + // 兼容常见包装结构:后端可能直接返回 {token, info}, + // 也可能包一层 {code, msg, data: {token, info}}(业务响应通用壳)。 + // data 存在就取 data,否则直接用 json,两种都能解析到。 + final payload = (json['data'] as Map?) ?? json; + return LoginResponse( + token: payload['token'] as String? ?? '', + info: UserInfo.fromJson( + (payload['info'] as Map?) ?? const {}, + ), + ); + } +} + +/// 用户信息(对齐 Vue 端 getUserInfo 的字段,未知的用 ?? 兜底) +class UserInfo { + const UserInfo({ + this.userId = 0, + this.userName = '', + this.account = '', + this.avatar = '', + this.phone = '', + this.isShowReport = false, + }); + + final int userId; + final String userName; + final String account; + final String avatar; + final String phone; + final bool isShowReport; + + factory UserInfo.fromJson(Map json) { + return UserInfo( + // userId 可能是字符串(后端统一风格),不能直接 as num + userId: _toInt(json['userId']), + userName: json['userName'] as String? ?? '', + account: json['account'] as String? ?? '', + avatar: json['avatar'] as String? ?? '', + phone: json['phone'] as String? ?? '', + isShowReport: json['isShowReport'] as bool? ?? false, + ); + } +} + +/// 兼容"字符串数字"(后端 userId 返回 "820466383020136") +int _toInt(dynamic v) { + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? 0; + return 0; +} \ No newline at end of file diff --git a/lib/core/api/class/api.dart b/lib/core/api/class/api.dart new file mode 100644 index 0000000..64b2302 --- /dev/null +++ b/lib/core/api/class/api.dart @@ -0,0 +1,117 @@ +import 'package:new_learn/core/api/class/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【class 模块接口】班级模块请求 —— 对标 Vue3 端 api/class/index.ts + api/userPoint/index.ts +/// +/// ClassApi:班级动态/成员/点赞/评论 +/// getUserClassPost → 班级动态分页(游标 pageIndex + prevPageId) +/// getClassUsers → 班级成员在线状态 +/// addUserLike → 点赞 / 取消点赞 +/// addUserComment → 评论 +/// +/// UserRankApi:积分排行榜 +/// getUserCount → 排行榜总人数(本班级 / 同年级 / 同云校) +/// getRankPage → 排行榜分页(游标分页:pageIndex + prevPageId) +/// +/// 注意:页面只调这里,不直接碰 Dio;异常不在这里 catch,交页面统一处理。 +/// ============================================================ +class ClassApi { + ClassApi._(); + + static final ClassApi instance = ClassApi._(); + + /// 班级动态列表(queryType: 0 我的动态 / 1 班级动态) + Future getUserClassPost({ + int queryType = 1, + int pageIndex = 1, + int pageSize = 10, + int prevPageId = 0, + }) async { + final data = await DioClient.instance.get( + '/api/v1/UserPost/GetUserClassPost', + query: { + 'queryType': queryType, + 'pageIndex': pageIndex, + 'pageSize': pageSize, + 'prevPageId': prevPageId, + }, + ); + return UserPostResponse.fromJson(data as Map); + } + + /// 班级成员(在线状态) + Future> getClassUsers() async { + final data = await DioClient.instance.get('/api/v1/User/GetClassUsers'); + final list = data is Map ? data['data'] : data; + if (list is List) { + return list + .whereType>() + .map(ClassUser.fromJson) + .toList(); + } + return const []; + } + + /// 点赞 / 取消点赞(likeStatus: true 点赞 / false 取消) + Future addUserLike({ + required int userPostId, + required bool likeStatus, + }) async { + await DioClient.instance.post('/api/v1/UserLike/AddUserLike', data: { + 'userPostId': userPostId, + 'likeStatus': likeStatus, + }); + } + + /// 评论动态 + Future addUserComment({ + required int userPostId, + required String content, + }) async { + await DioClient.instance.post('/api/v1/UserPost/AddUserCommon', data: { + 'userPostId': userPostId, + 'content': content, + }); + } +} + +/// 积分排行榜接口 +class UserRankApi { + UserRankApi._(); + + static final UserRankApi instance = UserRankApi._(); + + /// 排行榜总人数(本班级 / 同年级 / 同云校) + Future getUserCount() async { + final data = await DioClient.instance.get( + '/api/v1/UserPointsRecord/GetUserCount', + ); + return UserCountResult.fromJson(data as Map); + } + + /// 排行榜分页(游标分页:pageIndex + prevPageId) + /// [userRankType] 0 总榜 / 1 本周 / 2 上周 / 3 今天 / 4 昨天 + /// [userClass] 0 本班级 / 1 同年级 / 2 同云校 + Future getRankPage({ + required int pageIndex, + required int pageSize, + required int prevPageId, + required int userRankType, + required int userClass, + required bool desc, + }) async { + final data = await DioClient.instance.get( + '/api/v1/UserPointsRecord/GetUserRank', + query: { + 'pageIndex': pageIndex, + 'pageSize': pageSize, + 'prevPageId': prevPageId, + 'UserRankType': userRankType, + 'UserClass': userClass, + 'desc': desc, + }, + ); + return UserRankPagedList.fromJson(data as Map); + } +} \ No newline at end of file diff --git a/lib/core/api/class/model.dart b/lib/core/api/class/model.dart new file mode 100644 index 0000000..fb3d614 --- /dev/null +++ b/lib/core/api/class/model.dart @@ -0,0 +1,307 @@ +/// ============================================================ +/// 【class 模块模型】班级模块实体 —— 对标 Vue3 api/class/type.ts + api/userPoint/type.ts +/// +/// 内容(合并自原 features/class/models/): +/// 班级动态:UserPostCommonDto / UserPostDto / UserPostResponse / ClassUser +/// 积分排行:UserRank / UserRankPagedList / UserCountResult +/// +/// 序列化:手动 fromJson(响应壳 {code,message,data:{...}} 先解包 data,再逐字段取), +/// 不依赖 build_runner。数字用 _toInt 兜底字符串数字。 +/// +/// 说明:UserPostDto 提供 copyWith(点赞/评论乐观更新需要在列表内替换单条)。 +/// ============================================================ +library; + +// ============ 班级动态 ============ + +class UserPostCommonDto { + const UserPostCommonDto({ + this.userId = 0, + this.userName = '', + this.content = '', + this.createTime = '', + this.id = 0, + }); + + final int userId; + final String userName; + final String content; + final String createTime; + final int id; + + factory UserPostCommonDto.fromJson(Map json) => + UserPostCommonDto( + userId: _toInt(json['userId']), + userName: json['userName'] as String? ?? '', + content: json['content'] as String? ?? '', + createTime: json['createTime'] as String? ?? '', + id: _toInt(json['id']), + ); +} + +class UserPostDto { + const UserPostDto({ + this.userId = 0, + this.userName = '', + this.avatar = '', + this.classId = 0, + this.content = '', + this.likeCount = 0, + this.replyCount = 0, + this.subjectName = '', + this.createTime = '', + this.userLikeStatus = false, + this.commons = const [], + this.id = 0, + this.urls = const [], + this.type = 0, // 1 攻克任务 2 试题笔记 3 讲义笔记 + this.userLikeAvatars = const [], + }); + + final int userId; + final String userName; + final String avatar; + final int classId; + final String content; + final int likeCount; + final int replyCount; + final String subjectName; + final String createTime; + final bool userLikeStatus; + final List commons; + final int id; + final List urls; + final int type; + final List userLikeAvatars; + + factory UserPostDto.fromJson(Map json) => UserPostDto( + userId: _toInt(json['userId']), + userName: json['userName'] as String? ?? '', + avatar: json['avatar'] as String? ?? '', + classId: _toInt(json['classId']), + content: json['content'] as String? ?? '', + likeCount: _toInt(json['likeCount']), + replyCount: _toInt(json['replyCount']), + subjectName: json['subjectName'] as String? ?? '', + createTime: json['createTime'] as String? ?? '', + userLikeStatus: json['userLikeStatus'] as bool? ?? false, + commons: (json['commons'] as List? ?? const []) + .whereType>() + .map(UserPostCommonDto.fromJson) + .toList(), + id: _toInt(json['id']), + urls: (json['urls'] as List? ?? const []) + .whereType() + .toList(), + type: _toInt(json['type']), + userLikeAvatars: (json['userLikeAvatars'] as List? ?? const []) + .whereType() + .toList(), + ); + + UserPostDto copyWith({ + int? userId, + String? userName, + String? avatar, + int? classId, + String? content, + int? likeCount, + int? replyCount, + String? subjectName, + String? createTime, + bool? userLikeStatus, + List? commons, + int? id, + List? urls, + int? type, + List? userLikeAvatars, + }) { + return UserPostDto( + userId: userId ?? this.userId, + userName: userName ?? this.userName, + avatar: avatar ?? this.avatar, + classId: classId ?? this.classId, + content: content ?? this.content, + likeCount: likeCount ?? this.likeCount, + replyCount: replyCount ?? this.replyCount, + subjectName: subjectName ?? this.subjectName, + createTime: createTime ?? this.createTime, + userLikeStatus: userLikeStatus ?? this.userLikeStatus, + commons: commons ?? this.commons, + id: id ?? this.id, + urls: urls ?? this.urls, + type: type ?? this.type, + userLikeAvatars: userLikeAvatars ?? this.userLikeAvatars, + ); + } +} + +/// 班级动态分页响应 +/// +/// 响应壳:{code, message, data: {data: UserPostDto[], total, ...}} +/// 外层 data 是分页对象,内层 data 是动态数组。 +class UserPostResponse { + const UserPostResponse({ + this.data = const [], + this.total = 0, + this.pageIndex = 1, + this.pageSize = 10, + this.id = 0, + }); + + final List data; + final int total; + final int pageIndex; + final int pageSize; + final int id; + + factory UserPostResponse.fromJson(Map json) { + final payload = json['data'] is Map + ? json['data'] as Map + : json; + final list = payload['data']; + return UserPostResponse( + data: list is List + ? list + .whereType>() + .map(UserPostDto.fromJson) + .toList() + : const [], + total: _toInt(payload['total']), + pageIndex: _toInt(payload['pageIndex']), + pageSize: _toInt(payload['pageSize']), + id: _toInt(payload['id']), + ); + } +} + +class ClassUser { + const ClassUser({ + this.userId = 0, + this.userName = '', + this.avatar = '', + this.isOnline = false, + }); + + final int userId; + final String userName; + final String avatar; + final bool isOnline; + + factory ClassUser.fromJson(Map json) => ClassUser( + userId: _toInt(json['userId']), + userName: json['userName'] as String? ?? '', + avatar: json['avatar'] as String? ?? '', + isOnline: json['isOnline'] as bool? ?? false, + ); +} + +// ============ 积分排行 ============ + +class UserRank { + const UserRank({ + this.userName = '', + this.userId = 0, + this.userAvatar = '', + this.userClassName = '', + this.rank = 0, + this.points = 0, + }); + + final String userName; + final int userId; + final String userAvatar; + final String userClassName; + final int rank; + final double points; + + factory UserRank.fromJson(Map json) => UserRank( + userName: json['userName'] as String? ?? '', + userId: _toInt(json['userId']), + userAvatar: json['userAvatar'] as String? ?? '', + userClassName: json['userClassName'] as String? ?? '', + rank: _toInt(json['rank']), + points: _toDouble(json['points']), + ); +} + +class UserRankPagedList { + const UserRankPagedList({ + this.myRank, + this.userRanks = const [], + this.total = 0, + this.pageIndex = 1, + this.pageSize = 20, + }); + + final UserRank? myRank; + final List userRanks; + final int total; + final int pageIndex; + final int pageSize; + + factory UserRankPagedList.fromJson(Map json) { + final payload = json['data'] is Map + ? json['data'] as Map + : json; + // 兼容 userRanks / data 两种返回字段 + final ranksList = payload['userRanks'] is List + ? payload['userRanks'] as List + : (payload['data'] is List + ? payload['data'] as List + : const []); + return UserRankPagedList( + myRank: payload['myRank'] is Map + ? UserRank.fromJson(payload['myRank'] as Map) + : null, + userRanks: ranksList.whereType>().map(UserRank.fromJson).toList(), + total: _toInt(payload['total']), + pageIndex: _toInt(payload['pageIndex']), + pageSize: _toInt(payload['pageSize']), + ); + } +} + +class UserCountResult { + const UserCountResult({ + this.classUserCount, + this.gradeUserCount, + this.cloudSchoolUserCount, + }); + + final int? classUserCount; + final int? gradeUserCount; + final int? cloudSchoolUserCount; + + factory UserCountResult.fromJson(Map json) { + final payload = json['data'] is Map + ? json['data'] as Map + : json; + return UserCountResult( + classUserCount: _toIntOrNull(payload['classUserCount']), + gradeUserCount: _toIntOrNull(payload['gradeUserCount']), + cloudSchoolUserCount: _toIntOrNull(payload['cloudSchoolUserCount']), + ); + } +} + +// ============ 类型转换工具 ============ + +int _toInt(dynamic v) { + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? 0; + return 0; +} + +double _toDouble(dynamic v) { + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v) ?? 0; + return 0; +} + +int? _toIntOrNull(dynamic v) { + if (v == null) return null; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v); + return null; +} \ No newline at end of file diff --git a/lib/core/api/my_point/api.dart b/lib/core/api/my_point/api.dart new file mode 100644 index 0000000..3bede6b --- /dev/null +++ b/lib/core/api/my_point/api.dart @@ -0,0 +1,30 @@ +import 'package:new_learn/core/api/my_point/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【my_point 模块接口】积分接口 —— 对标 Vue3 端 api/userPoint/index.ts +/// +/// getRecordPage —— 积分记录分页(页码分页) +/// GET /api/v1/UserPointsRecord/GetUserPointRecordPagedList +/// 参数:pageIndex / pageSize +/// +/// 说明:排行榜相关接口(getUserCount / getUserRankPagedList)归属班级模块, +/// 统一收在 core/api/class/api.dart。 +/// ============================================================ +class UserPointApi { + UserPointApi._(); + + static final UserPointApi instance = UserPointApi._(); + + /// 积分记录分页(页码分页:pageIndex / pageSize) + Future getRecordPage({ + int pageIndex = 1, + int pageSize = 10, + }) async { + final data = await DioClient.instance.get( + '/api/v1/UserPointsRecord/GetUserPointRecordPagedList', + query: {'pageIndex': pageIndex, 'pageSize': pageSize}, + ); + return UserPointRecordPage.fromJson(data as Map); + } +} \ No newline at end of file diff --git a/lib/core/api/my_point/model.dart b/lib/core/api/my_point/model.dart new file mode 100644 index 0000000..2bfcca1 --- /dev/null +++ b/lib/core/api/my_point/model.dart @@ -0,0 +1,62 @@ +/// ============================================================ +/// 【my_point 模块模型】积分实体 —— 对标 Vue3 端 api/userPoint/type.ts +/// +/// UserPointRecord(积分变动记录)/ UserPointRecordPage(分页响应) +/// +/// 序列化:手动 fromJson(兼容 {code, message, data: {...}} 响应壳), +/// 等价原 point_model_original_version.dart 的自包含实现,不依赖 build_runner。 +/// ============================================================ +library; + +/// 单条积分变动记录 +class UserPointRecord { + const UserPointRecord({ + this.points = 0, + this.pointMessage = '', + this.type = 0, + this.bizTime = '', + }); + + final int points; // 积分变动值(正=加分,负=扣分) + final String pointMessage; // 变动说明 + final int type; // 类型 + final String bizTime; // 变动时间字符串 + + factory UserPointRecord.fromJson(Map json) { + return UserPointRecord( + points: (json['points'] as num?)?.toInt() ?? 0, + pointMessage: json['pointMessage'] as String? ?? '', + type: (json['type'] as num?)?.toInt() ?? 0, + bizTime: json['bizTime'] as String? ?? '', + ); + } +} + +/// 积分记录分页响应 +class UserPointRecordPage { + const UserPointRecordPage({ + this.data = const [], + this.total = 0, + this.pageIndex = 1, + this.pageSize = 10, + }); + + final List data; // 当前页记录 + final int total; // 总记录数 + final int pageIndex; + final int pageSize; + + /// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data + factory UserPointRecordPage.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return UserPointRecordPage( + data: (payload['data'] as List? ?? const []) + .whereType>() + .map(UserPointRecord.fromJson) + .toList(), + total: (payload['total'] as num?)?.toInt() ?? 0, + pageIndex: (payload['pageIndex'] as num?)?.toInt() ?? 1, + pageSize: (payload['pageSize'] as num?)?.toInt() ?? 10, + ); + } +} diff --git a/lib/core/app.dart b/lib/core/app.dart new file mode 100644 index 0000000..dacd5fb --- /dev/null +++ b/lib/core/app.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:new_learn/core/components/privacy_startup_gate.dart'; +import 'package:new_learn/core/config/app_config.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/providers/network_status_provider.dart'; +import 'package:new_learn/core/providers/privacy_provider.dart'; +import 'package:new_learn/core/router/app_router.dart'; +import 'package:new_learn/core/services/local_notification_service.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; // 复用 AppAdapt 适配配置 +import 'package:talker_flutter/talker_flutter.dart'; + +/// ============================================================ +/// 【TabApp】App 顶层入口(MaterialApp) +/// +/// 职责分工(对照 Vue 项目): +/// main.dart = 启动脚本(main 函数) +/// app.dart = 根组件:ScreenUtilInit + MaterialApp(主题 + 首页) +/// main_tab_page.dart = 底部 4 tab 主框架 +/// pages/ = 各业务页面 +/// +/// 日志体系(talker_flutter + talker_dio_logger): +/// 1. 全局唯一实例:core/logger/app_talker.dart 的 talker(全项目 import 即用); +/// 2. Dio 网络日志:core/network/dio_client.dart 已接入 TalkerDioLogger; +/// 3. App 内查看:本文件在 MaterialApp.builder 外层注入 TalkerWrapper + DebugTalkerBubble, +/// 非 Release 模式会出现右下角悬浮小圆球,点开进入 TalkerScreen 查看全部日志; +/// 4. Release 自动关闭:kReleaseMode 时不注入 TalkerWrapper/悬浮球,线上不可见。 +/// +/// 业务页面直接使用(不需要 new Talker): +/// import 'package:new_learn/core/logger/app_talker.dart'; +/// talker.debug('调试信息'); +/// talker.warning('警告'); +/// talker.error('错误'); +/// +/// 一、ScreenUtilInit 屏幕适配(flutter_screenutil 5.9.0): +/// designSize = 设计稿尺寸(这里 375x812 = iPhone 标准逻辑尺寸, +/// 如果你的设计稿是 750 宽(uni-app rpx),就写 Size(750, 1624))。 +/// 适配原理:按"实际屏幕 / 设计稿"的比例统一缩放,保证不同手机上 +/// UI 比例一致,不会大屏显小、小屏溢出。 +/// +/// 二、页面里怎么用(核心三件套): +/// .w 宽度/横向间距 Container(width: 100.w) +/// .h 高度/纵向间距 Container(height: 50.h) +/// .sp 字号 TextStyle(fontSize: 16.sp) +/// .r 圆角 BorderRadius.circular(12.r) +/// +/// 三、高频坑: +/// 1. 必须在 ScreenUtilInit 的 builder 内部才能用 .w/.h/.sp/.r, +/// 所以初始化要包在最外层(本文件就是最外层); +/// 2. 改 main.dart 或这里,必须 Shift+R 热重启,热重载不生效; +/// 3. 不要对图片宽高做 .w 缩放后还叠加 transform,会双倍缩放。 +/// ============================================================ +class TabApp extends StatelessWidget { + const TabApp({super.key}); + + @override + Widget build(BuildContext context) { + return ScreenUtilInit( + // 方案 B:平板(宽≥600dp)自动用大设计稿,缩放比例回到 ~1, + // 否则 Pad 上 .sp 会随宽度放大 2 倍+,字体爆炸。 + designSize: AppAdapt.designSizeOf(context), + minTextAdapt: true, // 字号最小自适应,防小屏文字过小 + splitScreenMode: true, // 支持分屏/平板 + // 兜底:字体放大封顶 1.2 倍(防横屏/极端宽屏) + fontSizeResolver: AppAdapt.fontSizeResolver, + builder: (context, child) { + // 品牌 × 环境 聚合:App 名 + 主题色板随品牌走(见 core/config/app_config.dart) + final brandTheme = AppConfig.theme; + return MaterialApp.router( + title: AppConfig.appName, + debugShowCheckedModeBanner: false, + theme: ThemeData( + useMaterial3: true, + // 品牌 seed 色生成整套 Material 色板(换品牌 = 全局换色) + colorSchemeSeed: brandTheme.seedColor, + scaffoldBackgroundColor: const Color(0xFFF5F7FA), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.white, + foregroundColor: Color(0xFF333333), + elevation: 0, + centerTitle: true, + ), + ), + // 方案 A:启动层隐私 Gate。 + // 这样冷启动时不会先看到登录页/主页闪一下,再被路由重定向到隐私页, + // 而是直接由全屏拦截页先接管。 + builder: (context, child) { + final content = + PrivacyStartupGate(child: child ?? const SizedBox.shrink()); + final bootstrapped = _LocalNotificationBootstrapper(child: content); + if (kReleaseMode) return bootstrapped; + return TalkerWrapper( + talker: talker, + child: DebugTalkerBubble( + child: _NetworkStatusGlobalLogger(child: bootstrapped), + ), + ); + }, + // 全局路由表:登录守卫 + 4 tab ShellRoute(见 app_router.dart) + routerConfig: appRouter, + ); + }, + ); + } +} + +class _LocalNotificationBootstrapper extends StatefulWidget { + const _LocalNotificationBootstrapper({required this.child}); + + final Widget child; + + @override + State<_LocalNotificationBootstrapper> createState() => + _LocalNotificationBootstrapperState(); +} + +class _LocalNotificationBootstrapperState + extends State<_LocalNotificationBootstrapper> { + @override + void initState() { + super.initState(); + privacyAcceptedNotifier.addListener(_onPrivacyChanged); + _onPrivacyChanged(); + } + + @override + void dispose() { + privacyAcceptedNotifier.removeListener(_onPrivacyChanged); + super.dispose(); + } + + void _onPrivacyChanged() { + if (!privacyAcceptedNotifier.value) return; + unawaited(LocalNotificationService.instance.ensureInitialized()); + } + + @override + Widget build(BuildContext context) => widget.child; +} + +class _NetworkStatusGlobalLogger extends ConsumerStatefulWidget { + const _NetworkStatusGlobalLogger({required this.child}); + + final Widget child; + + @override + ConsumerState<_NetworkStatusGlobalLogger> createState() => + _NetworkStatusGlobalLoggerState(); +} + +class _NetworkStatusGlobalLoggerState + extends ConsumerState<_NetworkStatusGlobalLogger> { + late final ProviderSubscription _sub; + + @override + void initState() { + super.initState(); + + final current = ref.read(networkStatusProvider); + talker.debug( + '网络状态(初始):' + 'types=[${current.networkTypes.map((e) => e.name).join(',')}] ' + 'hasInternet=${current.hasInternet} ' + 'hasBackend=${current.hasBackend}', + ); + + _sub = ref.listenManual(networkStatusProvider, (prev, next) { + talker.debug( + '网络状态变化:' + 'types=[${next.networkTypes.map((e) => e.name).join(',')}] ' + 'hasInternet=${next.hasInternet} ' + 'hasBackend=${next.hasBackend}', + ); + }); + } + + @override + void dispose() { + _sub.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/core/components/app_update_dialog.dart b/lib/core/components/app_update_dialog.dart new file mode 100644 index 0000000..3070950 --- /dev/null +++ b/lib/core/components/app_update_dialog.dart @@ -0,0 +1,157 @@ +import 'dart:io' show Platform; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:new_learn/core/version/remote_version_entity.dart'; + +/// ============================================================ +/// 【AppUpdateDialog】版本更新弹窗 —— 区分【强制更新】【可选更新】 +/// +/// 一、强制更新行为: +/// barrierDismissible: false(点外部不关闭) +/// PopScope 拦截返回按键(弹窗不可关闭) +/// 只有【立即更新】按钮 +/// +/// 二、可选更新行为: +/// 提供【稍后再说】【立即更新】两个按钮 +/// 点"稍后再说"关闭弹窗,本次生命周期不再重复弹窗 +/// +/// 三、点击"立即更新": +/// 安卓 → 打开外部浏览器访问下载地址(androidUrl) +/// iOS → 跳转 App Store 页面(iosUrl) +/// ============================================================ +class AppUpdateDialog extends StatelessWidget { + const AppUpdateDialog({ + super.key, + required this.remoteInfo, + required this.isForce, + }); + + final RemoteVersionEntity remoteInfo; + final bool isForce; + + /// 显示更新弹窗(统一入口) + static Future show( + BuildContext context, + RemoteVersionEntity remoteInfo, { + required bool isForce, + }) { + return showDialog( + context: context, + barrierDismissible: !isForce, // 强制更新时点外部不关闭 + builder: (ctx) => PopScope( + canPop: !isForce, // 强制更新时拦截返回键 + child: AppUpdateDialog(remoteInfo: remoteInfo, isForce: isForce), + ), + ); + } + + /// 点击"立即更新":区分平台跳转 + Future _launchUpdate() async { + final urlStr = Platform.isIOS + ? (remoteInfo.iosUrl ?? '') + : (remoteInfo.androidUrl ?? ''); + if (urlStr.isEmpty) return; + final uri = Uri.tryParse(urlStr); + if (uri == null) return; + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final updateDesc = remoteInfo.updateDesc ?? []; + + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)), + title: Row( + children: [ + Icon( + isForce ? Icons.warning_amber_rounded : Icons.system_update, + color: isForce ? Colors.orange : theme.colorScheme.primary, + size: 24.w, + ), + SizedBox(width: 8.w), + Text(isForce ? '重要更新' : '发现新版本'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 版本号 + Text( + 'v${remoteInfo.latestVersion ?? ''}', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF666666), + ), + ), + SizedBox(height: 12.h), + // 更新日志标题 + if (updateDesc.isNotEmpty) ...[ + Text( + '更新内容:', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 6.h), + // 更新日志列表 + ...updateDesc.map( + (desc) => Padding( + padding: EdgeInsets.only(bottom: 4.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '• ', + style: TextStyle( + fontSize: 13.sp, + color: const Color(0xFF666666), + ), + ), + Expanded( + child: Text( + desc, + style: TextStyle( + fontSize: 13.sp, + color: const Color(0xFF666666), + ), + ), + ), + ], + ), + ), + ), + ], + // 强制更新提示 + if (isForce) ...[ + SizedBox(height: 12.h), + Text( + '此版本为必要更新,请立即下载以继续使用', + style: TextStyle( + fontSize: 12.sp, + color: Colors.orange[700], + ), + ), + ], + ], + ), + actions: [ + if (!isForce) + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('稍后再说'), + ), + FilledButton( + onPressed: _launchUpdate, + child: Text(isForce ? '立即更新' : '立即更新'), + ), + ], + ); + } +} diff --git a/lib/core/components/audio_player_card.dart b/lib/core/components/audio_player_card.dart new file mode 100644 index 0000000..28e3ef8 --- /dev/null +++ b/lib/core/components/audio_player_card.dart @@ -0,0 +1,479 @@ +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; + +/// ============================================================ +/// 【AudioPlayerCard】通用音频播放控制条(在线音频) +/// +/// 一、为什么用 just_audio: +/// 学习类 App 的课程音频(在线/列表/倍速/后台)需求全在它强项上: +/// - 底层 ExoPlayer / AVPlayer / HTML5,原生解码稳定; +/// - 支持网络流、播放列表 gapless 连播、倍速/变速、循环、音频会话; +/// - 不带 UI,控制条由本组件自绘(对标 标签的 controls)。 +/// +/// 二、props 对照表(Vue 开发者速查): +/// url ← 在线音频地址(与 asset 二选一) +/// asset ← 本地音频资源 assets/audio/xxx.mp3(本地文件 seek 秒响应) +/// title ← 标题文本 +/// cover ← 封面缩略图 Widget(可选,显示在播放键左侧) +/// autoPlay ← autoplay(默认 false) +/// looping ← loop(默认 false,单曲循环) +/// volume ← volume(默认 1.0) +/// playbackSpeeds ← 倍速档位列表(默认 0.5x/1x/1.5x/2x) +/// +/// 三、生命周期(最重要): +/// 1. initState 里 setUrl 预加载,dispose 里 _player.dispose() 释放; +/// 2. 通过 WidgetsBindingObserver 监听 App 生命周期, +/// 切后台/来电自动暂停,防止声音还在后台播放; +/// 3. UI 用 StreamBuilder 监听 position / duration / playerState 三个流, +/// 播放进度、按钮状态实时刷新(Flutter 响应式写法)。 +/// +/// 四、高频坑: +/// 1. setUrl 返回 Future,必须 await 完成后再让用户操作, +/// 期间展示 loading(buffering 状态也走 loading 样式); +/// 2. just_audio 是 0.x 版本,0.9→0.10 改过 setAudioSource API, +/// 升级前先看 pub.dev changelog; +/// 3. Slider 的 max 不能是 0(未加载到时长时 clamp 到 ≥1); +/// 4. 切后台必须暂停:Android 上不暂停声音会一直播到结束, +/// 且会被系统判定为"异常后台播放"; +/// 5. 网络流 seek 跳播后必有一次重新缓冲(ExoPlayer 机制), +/// 本组件用 bufferedPositionStream 把缓冲进度画在进度条上 +/// (secondaryTrackValue),并显示"缓冲中 X%"提示,避免误以为卡死; +/// 6. ⚠ seek 能不能跳对位置,取决于服务器是否支持 HTTP Range: +/// 支持(返回 206 Partial Content)→ 从目标字节开始续传,跳播正常; +/// 不支持(返回 200 全量)→ ExoPlayer 无法字节定位,只能从头重新 +/// 下载,表现就是"点中间段位置被重置回起点"(且第二次打开有系统 +/// 缓存时会看似正常,极易误判)。验收新音频源: +/// curl -s -D - -o NUL -H "Range: bytes=0-1023" 看状态码。 +/// ============================================================ +class AudioPlayerCard extends StatefulWidget { + const AudioPlayerCard({ + super.key, + this.url, + this.asset, + this.title, + this.cover, + this.autoPlay = false, + this.looping = false, + this.volume = 1.0, + this.playbackSpeeds = const [0.5, 1.0, 1.5, 2.0], + }) : assert(url != null || asset != null, 'url 与 asset 必须传一个'); + + /// 在线音频地址(http/https 直链,与 asset 二选一) + final String? url; + + /// 本地音频资源(assets/audio/xxx.mp3,与 url 二选一; + /// 本地文件 seek 跳播无需重新缓冲,秒响应) + final String? asset; + + /// 标题文本(不传则显示"音频") + final String? title; + + /// 封面缩略图(可选,如课程封面) + final Widget? cover; + + /// 是否自动播放 + final bool autoPlay; + + /// 是否单曲循环 + final bool looping; + + /// 初始音量(0.0 ~ 1.0) + final double volume; + + /// 倍速档位列表 + final List playbackSpeeds; + + @override + State createState() => _AudioPlayerCardState(); +} + +class _AudioPlayerCardState extends State + with WidgetsBindingObserver { + /// 底层播放器(just_audio) + final AudioPlayer _player = AudioPlayer(); + + /// 是否正在加载(setUrl 未完成) + bool _loading = true; + + /// 加载/播放错误(null = 无错误) + String? _error; + + /// 当前倍速 + double _speed = 1.0; + + /// 是否正在拖动进度条(拖动中不触发 seek,松手才 seek 一次) + bool _dragging = false; + + /// 拖动中的临时位置(毫秒,用于滑块跟手) + double? _dragValue; + + @override + void initState() { + super.initState(); + // 监听 App 生命周期:切后台自动暂停 + WidgetsBinding.instance.addObserver(this); + _initPlayer(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + // 必须释放播放器,否则音频解码资源泄漏 + _player.dispose(); + super.dispose(); + } + + /// App 切后台 / 来电 → 自动暂停 + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) { + _player.pause(); + } + } + + /// 预加载音频源,设置初始音量/循环/倍速 + Future _initPlayer() async { + try { + // 本地资源用 setAsset(seek 秒响应),在线用 setUrl + if (widget.asset != null) { + await _player.setAsset(widget.asset!); + } else { + await _player.setUrl(widget.url!); + } + if (widget.volume != 1.0) { + await _player.setVolume(widget.volume); + } + if (widget.looping) { + await _player.setLoopMode(LoopMode.one); + } + if (widget.autoPlay) { + _player.play(); + } + if (!mounted) return; + setState(() => _loading = false); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e.toString(); + }); + } + } + + /// 播放/暂停切换 + Future _togglePlay() async { + if (_player.playing) { + await _player.pause(); + } else { + await _player.play(); + } + } + + /// 切换倍速 + Future _changeSpeed(double speed) async { + await _player.setSpeed(speed); + setState(() => _speed = speed); + } + + /// 时间格式化:mm:ss + String _fmt(Duration d) { + final m = d.inMinutes.toString().padLeft(2, '0'); + final s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFEEF1F5)), + ), + child: _buildContent(), + ); + } + + /// 三种状态:loading / 错误 / 播放器 + Widget _buildContent() { + if (_error != null) { + return Row( + children: [ + const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + '音频加载失败\n$_error', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFF999999), fontSize: 13), + ), + ), + ], + ); + } + if (_loading) { + return const Row( + children: [ + SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + SizedBox(width: 12), + Text('加载中...', style: TextStyle(color: Color(0xFF999999))), + ], + ); + } + return _buildPlayer(); + } + + /// 播放器主体:按钮行 + 进度行(StreamBuilder 实时刷新) + Widget _buildPlayer() { + return StreamBuilder( + stream: _player.playerStateStream, + builder: (context, stateSnap) { + final state = stateSnap.data; + final playing = state?.playing ?? false; + final buffering = state?.processingState == ProcessingState.buffering; + return StreamBuilder( + stream: _player.positionStream, + builder: (context, posSnap) { + final position = posSnap.data ?? Duration.zero; + return StreamBuilder( + // bufferedPositionStream:当前已缓冲到的位置。 + // 网络流 seek 跳播后必有一次重新缓冲,用这个把 + // "已缓冲区域"画在进度条上(secondaryTrackValue) + stream: _player.bufferedPositionStream, + builder: (context, buffSnap) { + final buffered = buffSnap.data ?? Duration.zero; + return StreamBuilder( + // just_audio 0.10 的 durationStream 是 Stream, + // 用 map 把 null(未加载到时长)归一成 Duration.zero + stream: _player.durationStream.map( + (d) => d ?? Duration.zero, + ), + builder: (context, durSnap) { + final duration = durSnap.data ?? Duration.zero; + // Slider 的 max 不能为 0,没拿到时长前保底 1 + //(clamp 返回 num,要 toDouble) + final max = duration.inMilliseconds + .toDouble() + .clamp(1, double.infinity) + .toDouble(); + final value = position.inMilliseconds + .toDouble() + .clamp(0, max) + .toDouble(); + final bufferedValue = buffered.inMilliseconds + .toDouble() + .clamp(0, max) + .toDouble(); + // 缓冲百分比(duration 为 0 时显示 0%) + final percent = duration.inMilliseconds > 0 + ? (buffered.inMilliseconds / + duration.inMilliseconds * + 100) + .round() + : 0; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ---- 第一行:封面 + 播放键 + 标题 + 倍速 ---- + Row( + children: [ + if (widget.cover != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 48, + height: 48, + child: widget.cover, + ), + ), + const SizedBox(width: 10), + ], + _buildPlayButton(playing, buffering), + const SizedBox(width: 4), + Expanded( + child: _buildTitle( + buffering: buffering, + percent: percent, + ), + ), + _buildSpeedMenu(), + ], + ), + const SizedBox(height: 8), + // ---- 第二行:当前时间 + 进度条 + 总时长 ---- + Row( + children: [ + Text( + _fmt(position), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF999999), + ), + ), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 14, + ), + ), + child: Slider( + // 拖动中显示手指位置(不被 positionStream 拉回) + value: _dragging && _dragValue != null + ? _dragValue! + : value, + max: max, + // 次要轨道 = 已缓冲区域(浅色) + secondaryTrackValue: bufferedValue, + activeColor: const Color(0xFF3B82F6), + inactiveColor: const Color(0xFFE3E8EF), + // 缓冲中禁用拖动,避免无效 seek 把播放位置打回开头 + onChanged: buffering + ? null + : (v) => setState(() { + _dragging = true; + _dragValue = v; + }), + // 松手才真正 seek 一次: + // 拖动中每 tick 都 seek 会让网络流反复重建缓冲, + // 导致位置被重置回起始(核心修复) + onChangeEnd: buffering + ? null + : (v) { + setState(() => _dragging = false); + _player.seek( + Duration(milliseconds: v.round()), + ); + }, + ), + ), + ), + Text( + _fmt(duration), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF999999), + ), + ), + ], + ), + ], + ); + }, + ); + }, + ); + }, + ); + }, + ); + } + + /// 播放/暂停按钮(buffering 时转圈) + Widget _buildPlayButton(bool playing, bool buffering) { + return IconButton( + iconSize: 42, + padding: EdgeInsets.zero, + color: const Color(0xFF3B82F6), + icon: buffering + ? const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2.5), + ) + : Icon( + playing ? Icons.pause_circle_filled : Icons.play_circle_filled, + ), + onPressed: buffering ? null : _togglePlay, + ); + } + + /// 标题区:标题 + 地址/缓冲提示小字 + Widget _buildTitle({required bool buffering, required int percent}) { + // 小字:缓冲中显示百分比,否则显示源地址 + final String subText; + if (buffering) { + subText = '缓冲中 $percent%...'; + } else if (widget.asset != null) { + subText = widget.asset!; + } else { + subText = widget.url ?? ''; + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title ?? (widget.asset != null ? '本地音频' : '音频'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 2), + Text( + subText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: buffering ? const Color(0xFFF59E0B) : const Color(0xFF999999), + ), + ), + ], + ); + } + + /// 倍速菜单(PopupMenuButton,对标倍速选择器) + Widget _buildSpeedMenu() { + return PopupMenuButton( + initialValue: _speed, + onSelected: _changeSpeed, + itemBuilder: (context) => widget.playbackSpeeds + .map( + (s) => PopupMenuItem( + value: s, + child: Text( + '${s}x', + style: TextStyle( + fontWeight: s == _speed ? FontWeight.w700 : FontWeight.w400, + color: s == _speed + ? const Color(0xFF3B82F6) + : const Color(0xFF333333), + ), + ), + ), + ) + .toList(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_speed}x', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF3B82F6), + ), + ), + const Icon(Icons.arrow_drop_down, color: Color(0xFF3B82F6)), + ], + ), + ), + ); + } +} diff --git a/lib/core/components/common_paged_list.dart b/lib/core/components/common_paged_list.dart new file mode 100644 index 0000000..779cd16 --- /dev/null +++ b/lib/core/components/common_paged_list.dart @@ -0,0 +1,330 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; + +/// ============================================================ +/// 【CommonPagedList】通用分页列表组件(对标 z-paging,基于 infinite_scroll_pagination 5.x) +/// +/// 一、为什么封装(对齐"很多很多分页接口和页面"的诉求): +/// 分页页面的共性逻辑——PagingController 生命周期、下拉刷新、触底加载、 +/// hasMore 判断、错误重试、空状态、没有更多 footer——全部内聚在组件内部, +/// 业务页面只需实现两个回调:load(网络请求)+ buildItem(渲染每行)。 +/// 写法体验对齐 z-paging,PagingController 完全隐藏,杜绝外部乱调用。 +/// +/// 二、核心 API(5.x 与 4.x 完全不同,别搜 4.x 老教程): +/// - PagingController 构造直接传 getNextPageKey + fetchPage 两个回调; +/// - PagingListener 监听 controller,把 state + fetchNextPage 交给渲染层; +/// - PagedListView/PagedGridView 接收 state/fetchNextPage/builderDelegate; +/// - PagedChildBuilderDelegate 提供 6 个 indicator builder(首屏/翻页的 +/// loading、错误、空状态、没有更多),不传则用库内置英文默认。 +/// +/// 三、封装层做的 4 件事: +/// 1. firstPageKey 透传:getNextPageKey 在 keys 为空时返回 firstPageKey +/// (默认 1,正好对齐 pageIndex 从 1 开始),之后每次 +1; +/// 2. hasMore 翻译:业务 load 返回 PagingResult.hasNext,封装层记录到 +/// _hasMore,getNextPageKey 读到 false 就返回 null(库自动停); +/// 3. 错误接管:业务 load 直接抛异常即可,库自动渲染错误+重试按钮, +/// 业务不写 try-catch; +/// 4. 生命周期:controller 在 State.dispose 里销毁,防内存泄漏。 +/// +/// 四、游标分页(本项目接口特色)适配: +/// 接口要 pageIndex + prevPageId 两个参数,而 load 只给 pageKey。 +/// 解法:pageKey 当 pageIndex,游标 prevPageId 在业务页面闭包里维护一个 +/// 私有变量(首次 0,每次从响应更新)。见 my_point_page.dart 的 load。 +/// +/// 五、高频坑(本组件已处理): +/// 1. PagedChildBuilderDelegate 没有 separatorBuilder 参数,分隔线要走 +/// PagedListView.separated 构造,本组件已按参数自动切换; +/// 2. RefreshIndicator 要求子组件 AlwaysScrollableScrollPhysics,否则 +/// 内容不满一屏下拉手势无效,本组件已内置; +/// 3. 首屏加载由库自动触发(status == loadingFirstPage 时挂载即 fetch), +/// 下拉刷新调 controller.refresh() 重置后同样自动重载; +/// 4. 【致命坑】_hasMore 必须在刷新时重置为 true:全部分页加载完后 +/// _hasMore=false,若不重置,refresh 重置 state 后首屏请求会被 +/// getNextPageKey 拒绝,状态永远卡在 loadingFirstPage 死循环, +/// 表现为"不停转圈且不再拉接口"(本组件 _handleRefresh 已处理); +/// 5. 若未来需要"返回列表页刷新",可给组件加 GlobalKey 暴露 refresh(), +/// 当前页面每次 push 都是新 State 天然重载,无需此能力。 +/// ============================================================ + +/// 统一分页返回契约:所有分页接口都翻译成这个结构返回给组件 +class PagingResult { + const PagingResult({required this.data, this.hasNext = false}); + + /// 本页数据列表 + final List data; + + /// 是否还有下一页(组件根据它决定是否继续加载) + final bool hasNext; +} + +/// 分页加载回调:业务页面实现它,只写网络请求 + 返回数据,不接触分页库 +typedef PagedLoad = Future> Function(int pageKey); + +class CommonPagedList extends StatefulWidget { + const CommonPagedList({ + super.key, + required this.load, + required this.buildItem, + this.firstPageKey = 1, + this.gridDelegate, + this.separatorBuilder, + this.padding, + this.emptyWidget, + this.noMoreText = '— 没有更多了 —', + this.invisibleItemsThreshold = 0, + this.enableRefresh = true, + }); + + /// 分页加载回调(业务页面唯一必须实现的逻辑之一) + /// [pageKey] 首屏为 [firstPageKey],之后自动 +1,业务直接当 pageIndex 用 + final PagedLoad load; + + /// 单条渲染回调 (context, item, index) + final Widget Function(BuildContext context, T item, int index) buildItem; + + /// 首页页码,默认 1(对齐 pageIndex 从 1 开始) + final int firstPageKey; + + /// 传了 gridDelegate 就渲染 PagedGridView,否则 PagedListView + final SliverGridDelegate? gridDelegate; + + /// 行间分隔线(不传则无分隔线) + final Widget Function(BuildContext context, int index)? separatorBuilder; + + /// 列表内边距 + final EdgeInsetsGeometry? padding; + + /// 空列表占位(不传显示默认"暂无数据") + final Widget? emptyWidget; + + /// "没有更多"底部文案 + final String noMoreText; + + /// 距离底部还剩几项时触发加载下一页。 + /// 默认 0 = 滚到最后一个 item 才开始加载(避免一进页面就预拉第二页); + /// 若希望滚动更顺滑可调大(如 3),代价是提前多拉一页。 + final int invisibleItemsThreshold; + + /// 是否启用下拉刷新(默认开启) + final bool enableRefresh; + + @override + State> createState() => _CommonPagedListState(); +} + +class _CommonPagedListState extends State> { + /// 业务返回的"是否还有更多",getNextPageKey 据此决定是否继续 + bool _hasMore = true; + + /// PagingController 完全封装在 State 内部(对标 z-paging 内置 controller), + /// 业务页面永远拿不到它,防止外部乱调用破坏分页状态。 + late final PagingController _controller = PagingController( + // 每次 fetch 前调用:返回本次要加载的 pageKey,返回 null 表示没有更多 + getNextPageKey: (state) { + if (!_hasMore) return null; // 业务说没有更多了 + if (state.lastPageIsEmpty) return null; // 兜底:空页即结束 + final lastKey = state.keys?.last; + return lastKey == null ? widget.firstPageKey : lastKey + 1; + }, + // fetch 一页:调用业务 load,把 PagingResult 翻译成 List 交给分页库 + fetchPage: (pageKey) async { + final result = await widget.load(pageKey); + _hasMore = result.hasNext; + return result.data; + }, + ); + + @override + void dispose() { + // 内存泄漏红线:controller 必须销毁(它会释放内部监听和 fetch 操作) + _controller.dispose(); + super.dispose(); + } + + /// 下拉刷新: + /// ① 先重置 _hasMore(否则全部分页加载完后 _hasMore=false, + /// refresh 重置 state 后第一页请求会被 getNextPageKey 拒绝, + /// 导致永远停在 loadingFirstPage 死循环不拉接口——实测 bug); + /// ② controller.refresh() 重置分页状态(库自动重新请求第一页); + /// ③ 等待首屏加载结束再收起 RefreshIndicator 转圈(体验对齐原生)。 + Future _handleRefresh() async { + _hasMore = true; + _controller.refresh(); + + // 监听状态直到离开 loadingFirstPage(加载完成/失败/空) + final completer = Completer(); + void listener() { + if (_controller.status != PagingStatus.loadingFirstPage && + !completer.isCompleted) { + completer.complete(); + } + } + + _controller.addListener(listener); + listener(); // 立即检查一次:refresh 后可能已同步进入 loading + await completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () {}, + ); + _controller.removeListener(listener); + } + + @override + Widget build(BuildContext context) { + return PagingListener( + controller: _controller, + builder: (context, state, fetchNextPage) { + final delegate = _buildDelegate(state, fetchNextPage); + final Widget pagedView = _buildPagedView(state, fetchNextPage, delegate); + if (!widget.enableRefresh) return pagedView; + return RefreshIndicator( + onRefresh: _handleRefresh, + child: pagedView, + ); + }, + ); + } + + /// 根据参数组装 PagedListView / PagedListView.separated / PagedGridView + Widget _buildPagedView( + PagingState state, + VoidCallback fetchNextPage, + PagedChildBuilderDelegate delegate, + ) { + // RefreshIndicator 要求子组件可滚动;内容不满一屏时也必须能下拉 + final physics = + widget.enableRefresh ? const AlwaysScrollableScrollPhysics() : null; + + if (widget.gridDelegate != null) { + return PagedGridView( + state: state, + fetchNextPage: fetchNextPage, + gridDelegate: widget.gridDelegate!, + builderDelegate: delegate, + padding: widget.padding, + physics: physics, + ); + } + if (widget.separatorBuilder != null) { + return PagedListView.separated( + state: state, + fetchNextPage: fetchNextPage, + separatorBuilder: widget.separatorBuilder!, + builderDelegate: delegate, + padding: widget.padding, + physics: physics, + ); + } + return PagedListView( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: delegate, + padding: widget.padding, + physics: physics, + ); + } + + /// 组装 6 个状态 UI(loading / 错误 / 空 / 没有更多),业务可传空状态覆盖 + PagedChildBuilderDelegate _buildDelegate( + PagingState state, + VoidCallback fetchNextPage, + ) { + return PagedChildBuilderDelegate( + itemBuilder: widget.buildItem, + invisibleItemsThreshold: widget.invisibleItemsThreshold, + // ---------- 首屏 loading ---------- + firstPageProgressIndicatorBuilder: (_) => + const Center(child: CircularProgressIndicator()), + // ---------- 首屏失败:整页错误 + 重试按钮 ---------- + firstPageErrorIndicatorBuilder: (_) => _FirstPageError( + error: state.error, + onRetry: fetchNextPage, + ), + // ---------- 翻页 loading(列表底部) ---------- + newPageProgressIndicatorBuilder: (_) => Padding( + padding: EdgeInsets.symmetric(vertical: 14.h), + child: const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + ), + ), + // ---------- 翻页失败(列表底部,点击重试) ---------- + newPageErrorIndicatorBuilder: (_) => _ErrorFooter(onRetry: fetchNextPage), + // ---------- 空状态 ---------- + noItemsFoundIndicatorBuilder: (_) => + widget.emptyWidget ?? + Center( + child: Text( + '暂无数据', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), + ), + ), + // ---------- 没有更多(列表底部) ---------- + noMoreItemsIndicatorBuilder: (_) => Padding( + padding: EdgeInsets.symmetric(vertical: 14.h), + child: Center( + child: Text( + widget.noMoreText, + style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)), + ), + ), + ), + ); + } +} + +/// 首屏加载失败:错误图标 + 文案 + 重新加载按钮 +class _FirstPageError extends StatelessWidget { + const _FirstPageError({required this.error, required this.onRetry}); + + final Object? error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.wifi_off_rounded, size: 48.w, color: Colors.grey.shade400), + SizedBox(height: 12.h), + Text( + '加载失败,请检查网络', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), + ), + SizedBox(height: 16.h), + FilledButton(onPressed: onRetry, child: const Text('重新加载')), + ], + ), + ); + } +} + +/// 翻页失败 footer:底部"加载失败,点击重试" +class _ErrorFooter extends StatelessWidget { + const _ErrorFooter({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onRetry, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 14.h), + child: Center( + child: Text( + '加载失败,点击重试', + style: TextStyle(fontSize: 13.sp, color: const Color(0xFF3B82F6)), + ), + ), + ), + ); + } +} diff --git a/lib/core/components/image_preview_viewer.dart b/lib/core/components/image_preview_viewer.dart new file mode 100644 index 0000000..1970ef1 --- /dev/null +++ b/lib/core/components/image_preview_viewer.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; + +/// ============================================================ +/// 【ImagePreviewViewer】通用图片预览组件 +/// +/// 一、作用: +/// 统一承接项目里的图片大图预览:头像、题目图、讲义图、笔记图, +/// 都走这一套,不再每个页面各自写 Dialog/PageView。 +/// +/// 二、能力: +/// 1. 单图 / 多图预览; +/// 2. 双指缩放、拖拽、双击放大; +/// 3. 多图左右切换; +/// 4. 可选 HeroTag,缩略图到大图过渡更顺滑。 +/// +/// 三、高频坑: +/// 1. 预览页是全屏页面,外层最好直接 push,不要塞进受限高度的 Dialog; +/// 2. Widget 是配置,不是真实 View。这里传入的是 ImageProvider, +/// 所以网络图、本地图、内存图都能统一处理; +/// 3. 多图切换本质还是 PageView 横向滚动,PhotoView 负责缩放和平移。 +/// ============================================================ +class PreviewImageItem { + const PreviewImageItem({ + required this.imageProvider, + this.heroTag, + this.label, + }); + + final ImageProvider imageProvider; + final Object? heroTag; + final String? label; +} + +class ImagePreviewViewer extends StatefulWidget { + const ImagePreviewViewer({ + super.key, + required this.items, + this.initialIndex = 0, + }); + + final List items; + final int initialIndex; + + static Future show( + BuildContext context, { + required List items, + int initialIndex = 0, + }) async { + if (items.isEmpty) return; + // 进入预览:隐藏状态栏 + 导航栏,图片铺满全屏 + await SystemChrome.setEnabledSystemUIMode( + SystemUiMode.immersiveSticky, + ); + await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => ImagePreviewViewer( + items: items, + initialIndex: initialIndex, + ), + ), + ); + // 退出预览:恢复系统 UI + await SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + ); + } + + static Future showSingle( + BuildContext context, { + required ImageProvider imageProvider, + Object? heroTag, + String? label, + }) { + return show( + context, + items: [ + PreviewImageItem( + imageProvider: imageProvider, + heroTag: heroTag, + label: label, + ), + ], + ); + } + + @override + State createState() => _ImagePreviewViewerState(); +} + +class _ImagePreviewViewerState extends State { + late final PageController _pageController; + late int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final items = widget.items; + final topPadding = MediaQuery.paddingOf(context).top; + + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + PhotoViewGallery.builder( + itemCount: items.length, + pageController: _pageController, + backgroundDecoration: const BoxDecoration(color: Colors.black), + onPageChanged: (index) { + setState(() { + _currentIndex = index; + }); + }, + builder: (context, index) { + final item = items[index]; + return PhotoViewGalleryPageOptions( + imageProvider: item.imageProvider, + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 3, + heroAttributes: item.heroTag == null + ? null + : PhotoViewHeroAttributes(tag: item.heroTag!), + onTapUp: (tapContext, details, value) => + Navigator.of(tapContext).maybePop(), + ); + }, + loadingBuilder: (context, event) { + final expected = event?.expectedTotalBytes; + final loaded = event?.cumulativeBytesLoaded ?? 0; + final progress = + expected != null && expected > 0 ? loaded / expected : null; + return Center( + child: SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + value: progress, + color: Colors.white, + ), + ), + ); + }, + ), + if (items.length > 1) + Positioned( + top: topPadding + 16, + right: 16, + child: Text( + '${_currentIndex + 1}/${items.length}', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/core/components/persistent_snap_sheet.dart b/lib/core/components/persistent_snap_sheet.dart new file mode 100644 index 0000000..2fe52e1 --- /dev/null +++ b/lib/core/components/persistent_snap_sheet.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +/// ============================================================ +/// 【PersistentSnapSheet】常驻多停靠底部面板(smooth_sheets 封装) +/// +/// 一、为什么封装: +/// smooth_sheets 原生 API 组件多(SheetViewport / Sheet / SheetSnapGrid / +/// SheetPhysics / MaterialSheetDecoration / SheetScrollConfiguration), +/// 每次使用都要写一长串配置。本组件把"常驻 + 多停靠点 + 内部可滚动" +/// 这个最常见场景封装成一个开箱即用的 Widget。 +/// +/// 二、核心参数: +/// snapFactors : 停靠点列表(视口高度比例),如 [0.12, 0.5, 0.85] +/// · 最少 2 个,推荐 3 个 +/// · 值域 0.0~1.0,0 = 完全隐藏,1 = 全屏 +/// child : 面板内容(通常顶层是 Column,内部放 ListView 等) +/// controller : 可选,用于代码控制面板跳到某个停靠点 +/// +/// 三、默认行为: +/// · 初始位置:snapFactors 中间值 +/// · 物理引擎:BouncingSheetPhysics(iOS 弹性风格) +/// · 外观:白色背景 + 顶部圆角 16 + 轻微阴影 +/// · 滚动联动:onlyFromTop(列表滚到顶后才能拖面板) +/// +/// 四、使用示例: +/// PersistentSnapSheet( +/// snapFactors: [0.12, 0.5, 0.85], +/// child: Column( +/// children: [ +/// // 拖拽手柄 +/// _buildHandle(), +/// // 可滚动内容 +/// Expanded(child: ListView(...)), +/// ], +/// ), +/// ) +/// +/// 五、smooth_sheets 关键组件对照: +/// SheetViewport → 非模态面板的容器(提供布局边界) +/// Sheet → 面板本体(拖拽 + 吸附 + 滚动联动) +/// SheetSnapGrid → 定义停靠点集合 +/// SheetOffset → 停靠点位置(factor = 视口高度比例) +/// SheetPhysics → 物理引擎(弹性 / 夹紧) +/// MaterialSheetDecoration → 外观(Material 风格:圆角/阴影/颜色) +/// SheetScrollConfiguration → 滚动联动策略 +/// ============================================================ +class PersistentSnapSheet extends StatelessWidget { + const PersistentSnapSheet({ + super.key, + required this.snapFactors, + required this.child, + this.controller, + this.physics, + this.decoration, + this.initialOffsetIndex, + }) : assert(snapFactors.length >= 2, '至少需要 2 个停靠点'); + + /// 停靠点因子列表(视口高度比例,0.0~1.0) + /// 例如 [0.12, 0.5, 0.85] = 底部收起 / 半屏 / 大半屏 + final List snapFactors; + + /// 面板内容(推荐顶层 Column:手柄 + Expanded(可滚动内容)) + final Widget child; + + /// 可选控制器(代码控制面板位置:animateTo / snap 等) + final SheetController? controller; + + /// 物理引擎(默认 BouncingSheetPhysics = iOS 弹性风格) + final SheetPhysics? physics; + + /// 面板外观(默认白色 + 圆角 16 + 阴影 4) + final SheetDecoration? decoration; + + /// 初始停靠点索引(默认取中间值) + final int? initialOffsetIndex; + + @override + Widget build(BuildContext context) { + // 将因子列表转为 SheetOffset 列表 + final snaps = snapFactors.map((f) => SheetOffset(f)).toList(); + + // 初始位置:取中间停靠点 + final initialIdx = initialOffsetIndex ?? (snapFactors.length ~/ 2); + + return SheetViewport( + child: Sheet( + controller: controller, + initialOffset: SheetOffset(snapFactors[initialIdx]), + snapGrid: SheetSnapGrid(snaps: snaps), + physics: physics ?? const BouncingSheetPhysics(), + // 滚动联动:onlyFromTop = 列表滚到顶后才能下拉面板 + scrollConfiguration: const SheetScrollConfiguration( + scrollSyncMode: SheetScrollHandlingBehavior.onlyFromTop, + ), + decoration: decoration ?? + MaterialSheetDecoration( + size: SheetSize.stretch, + color: Colors.white, + elevation: 4, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: child, + ), + ); + } +} diff --git a/lib/core/components/privacy_startup_gate.dart b/lib/core/components/privacy_startup_gate.dart new file mode 100644 index 0000000..2030dbb --- /dev/null +++ b/lib/core/components/privacy_startup_gate.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:new_learn/core/providers/privacy_provider.dart'; +import 'package:new_learn/features/auth/privacy_consent_page.dart'; + +/// ============================================================ +/// 【PrivacyStartupGate】启动层隐私拦截 +/// +/// 方案 A: +/// 放在 MaterialApp.router.builder 这一层。 +/// 好处是冷启动时不会先闪一下登录页/主页,再被重定向到隐私页, +/// 而是直接用全屏 Gate 先拦住。 +/// +/// 和 go_router 守卫的分工: +/// - StartupGate:负责首次启动的"视觉第一拦"; +/// - Router redirect:负责深链 / 刷新 / 运行中导航时的兜底拦截。 +/// ============================================================ +class PrivacyStartupGate extends StatelessWidget { + const PrivacyStartupGate({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: privacyAcceptedNotifier, + builder: (context, accepted, _) { + if (accepted) return child; + + // 关键点: + // 启动隐私 Gate 不能把 router child 整个替换掉,否则底下真正的根 + // Navigator 不会建立起来,协议正文页就没有地方 push。 + // 这里改成"路由树先建好但隐藏",再用隐私页盖在最上层。 + // + // 注意:PrivacyConsentPage 在 builder 层,context 位于 MaterialApp + // 内部 Navigator 之上,showDialog 找不到 Navigator 会失败。 + // 所以必须额外包一层 Navigator,给弹窗提供 push 目标。 + return Stack( + fit: StackFit.expand, + children: [ + Offstage( + offstage: true, + child: child, + ), + Navigator( + onGenerateRoute: (settings) => PageRouteBuilder( + settings: settings, + pageBuilder: (_, __, ___) => const PrivacyConsentPage(), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/core/components/rich_text_content.dart b/lib/core/components/rich_text_content.dart new file mode 100644 index 0000000..b4b25d6 --- /dev/null +++ b/lib/core/components/rich_text_content.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; +import 'package:new_learn/core/components/audio_player_card.dart'; +import 'package:new_learn/core/components/video_player_view.dart'; + +/// ============================================================ +/// 【RichTextContent】富文本渲染组件(题目/解析 HTML → Flutter widget) +/// +/// 一、为什么用 flutter_widget_from_html_core: +/// 1. 纯 widget 渲染(非 WebView)→ 题目分页列表性能好,全平台含 Web; +/// 2. 支持 70+ 标签、内联 CSS、表格横滚、sub/sup 上标下标; +/// 3. 活跃维护(0.17.2),要求 Flutter 3.32+(本项目满足)。 +/// 用 core 版而不是 enhanced:audio/video 标签我们要用自己封装的 +/// 组件渲染(见下文扩展点),不需要 fwfh 全家桶。 +/// +/// 二、props 对照表: +/// html ← HTML 字符串(必填) +/// textStyle ← 全局文本样式(字号调节/深色模式的入口) +/// onTapUrl ← 链接点击回调(返回 true 表示已处理) +/// customStylesBuilder ← 自定义标签样式(如把 .answer 高亮) +/// customWidgetBuilder ← 自定义元素替换(把任意标签换成业务 widget) +/// renderMode ← 渲染模式(默认 column;列表预览可用 listView) +/// +/// 三、内置扩展点(本组件已经帮你处理的): +/// 1. 标签 → 自动替换成 AudioPlayerCard(本项目封装的音频控制条) +/// 有 title 属性则作为标题,无则显示"题目音频"; +/// 2. 标签 → 自动替换成 VideoPlayerView(本项目内嵌播放器, +/// 无 Scaffold/AppBar),默认 autoPlay: false(避免富文本里 +/// 多个视频同时解码占内存),取 title 属性作为错误提示; +/// 3. 公式(\(...\) LaTeX)→ 需要 flutter_math_fork,后续扩展。 +/// +/// 四、学生端场景: +/// - 字号调节:外部用 Slider 控制 textStyle.fontSize 即可(demo 页示范); +/// - 深色模式:外部传 textStyle.color,页面背景由业务层控制; +/// - 链接安全:默认拦截所有链接(防止题目里夹带外部危险链接), +/// 需要放行时传 onTapUrl 自行处理。 +/// +/// 五、高频坑: +/// 1. 相对路径 src(如 /uploads/xxx.png)必须由后端返回完整 URL, +/// 或业务层在渲染前拼接 baseUrl,否则图片加载 404; +/// 2. 内联 style 只支持常用 CSS,复杂 flex/grid 布局会降级为块级排列; +/// 3. customWidgetBuilder 返回的 widget 是块级(占整行), +/// 想做行内替换请用 InlineCustomWidget 包裹; +/// 4. 不要用 WebView 方案渲染题目(每题一个 WebView,列表会卡死)。 +/// ============================================================ +class RichTextContent extends StatelessWidget { + const RichTextContent({ + super.key, + required this.html, + this.textStyle, + this.onTapUrl, + this.customStylesBuilder, + this.customWidgetBuilder, + this.renderMode = RenderMode.column, + }); + + /// HTML 字符串(后端返回) + final String html; + + /// 全局文本样式(字号/颜色的统一入口) + final TextStyle? textStyle; + + /// 链接点击回调(返回 true 表示已处理,不返回 true 且是锚点时自动滚动) + final Future Function(String url)? onTapUrl; + + /// 自定义标签样式(如 .answer 高亮、图片圆角等) + final CustomStylesBuilder? customStylesBuilder; + + /// 自定义元素替换(audio 已被内置替换,video/公式等在此扩展) + final CustomWidgetBuilder? customWidgetBuilder; + + /// 渲染模式:column(默认,嵌在滚动容器里)/ listView(页面级) + final RenderMode renderMode; + + @override + Widget build(BuildContext context) { + return HtmlWidget( + html, + textStyle: textStyle, + onTapUrl: onTapUrl ?? _defaultOnTapUrl, + customStylesBuilder: customStylesBuilder, + customWidgetBuilder: _combinedCustomWidget, + renderMode: renderMode, + ); + } + + /// 合并默认替换 + 用户自定义替换 + CustomWidgetBuilder get _combinedCustomWidget => (element) { + // 内置: → 本项目 AudioPlayerCard + if (element.localName == 'audio') { + final src = element.attributes['src']; + if (src != null && src.isNotEmpty) { + // 支持 asset:// 协议 → 本地音频(seek 秒响应) + // 注意:网络源必须支持 HTTP Range(响应 206), + // 否则 ExoPlayer 无法字节定位,seek 会失效"回到起始"。 + // 免费测试站(如 samplelib)常返回 200 不支持 Range,请勿用。 + final isAsset = src.startsWith('asset://'); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: AudioPlayerCard( + url: isAsset ? null : src, + asset: isAsset ? src.substring('asset://'.length) : null, + title: element.attributes['title'] ?? '题目音频', + ), + ); + } + } + // 内置: → 本项目 VideoPlayerView(内嵌播放器) + // 默认不自动播,避免富文本里多个视频同时解码占内存 + if (element.localName == 'video') { + final src = element.attributes['src']; + if (src != null && src.isNotEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: VideoPlayerView( + url: src, + title: element.attributes['title'] ?? '题目视频', + autoPlay: false, + ), + ); + } + } + // 透传用户自定义替换(公式等扩展点) + return customWidgetBuilder?.call(element); + }; + + /// 默认链接拦截:防止题目里夹带外部危险链接 + static Future _defaultOnTapUrl(String url) async { + debugPrint('[RichTextContent] 已拦截外部链接: $url'); + return true; + } +} diff --git a/lib/core/components/user_circle_avatar.dart b/lib/core/components/user_circle_avatar.dart new file mode 100644 index 0000000..714054c --- /dev/null +++ b/lib/core/components/user_circle_avatar.dart @@ -0,0 +1,24 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +/// ============================================================ +/// 【UserCircleAvatar】圆形网络头像 +/// +/// 通用组件:加载失败兜底灰底、占位背景。班级动态、积分排行等页面共用。 +/// ============================================================ +class UserCircleAvatar extends StatelessWidget { + const UserCircleAvatar({super.key, required this.url, required this.radius}); + + final String url; + final double radius; + + @override + Widget build(BuildContext context) { + return CircleAvatar( + radius: radius, + backgroundColor: const Color(0xFFF0F4FA), + backgroundImage: CachedNetworkImageProvider(url), + onBackgroundImageError: (_, _) {}, + ); + } +} diff --git a/lib/core/components/video_player_page.dart b/lib/core/components/video_player_page.dart new file mode 100644 index 0000000..2ecc430 --- /dev/null +++ b/lib/core/components/video_player_page.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:chewie/chewie.dart'; +import 'package:new_learn/core/components/video_player_view.dart'; + +/// ============================================================ +/// 【VideoPlayerPage】视频播放整页(Scaffold + VideoPlayerView) +/// +/// 一、和 VideoPlayerView 的关系(拆分说明): +/// - 本页是"整页播放"场景:黑底 + AppBar(带返回)+ 居中播放器; +/// - 内嵌场景(富文本 、列表卡片)请直接用 VideoPlayerView, +/// 它没有 Scaffold/AppBar,可嵌任意布局。 +/// 两者共用同一套 props 和播放核心,逻辑都在 video_player_view.dart。 +/// +/// 二、props 全部透传给 VideoPlayerView,用法一致: +/// url / title / aspectRatio / autoPlay / looping / muted / volume / +/// startAt / isLive / showControls / allowFullScreen / +/// allowPlaybackSpeedChanging / allowMuting / allowDragging / +/// placeholder / playbackSpeeds / hideControlsTimer / materialProgressColors +/// 详见 video_player_view.dart 的文档注释。 +/// ============================================================ +class VideoPlayerPage extends StatelessWidget { + const VideoPlayerPage({ + super.key, + required this.url, + this.title, + this.aspectRatio, + this.autoPlay = true, + this.looping = false, + this.muted = false, + this.volume = 1.0, + this.startAt = Duration.zero, + this.isLive = false, + this.showControls = true, + this.allowFullScreen = true, + this.allowPlaybackSpeedChanging = true, + this.allowMuting = true, + this.allowDragging = true, + this.placeholder, + this.playbackSpeeds, + this.hideControlsTimer, + this.materialProgressColors, + }); + + /// 视频地址(http/https 直链,建议后端统一转 H.264 MP4) + final String url; + + /// 页面标题(AppBar 显示) + final String? title; + + /// 视频宽高比;不传 = 跟随视频本身尺寸 + final double? aspectRatio; + + /// 是否自动播放 + final bool autoPlay; + + /// 是否循环播放 + final bool looping; + + /// 初始是否静音 + final bool muted; + + /// 初始音量(0.0 ~ 1.0) + final double volume; + + /// 起始播放位置 + final Duration startAt; + + /// 直播模式:隐藏进度条/快进/倍速 + final bool isLive; + + /// 是否显示控制条 + final bool showControls; + + /// 是否允许全屏 + final bool allowFullScreen; + + /// 是否允许切换倍速 + final bool allowPlaybackSpeedChanging; + + /// 是否允许静音(控制条上的静音按钮) + final bool allowMuting; + + /// 是否允许拖拽进度 + final bool allowDragging; + + /// 封面占位图(加载完成前展示,对标 poster) + final Widget? placeholder; + + /// 倍速档位列表(默认 0.25x ~ 2.0x,传 null 用默认) + final List? playbackSpeeds; + + /// 控制条自动隐藏延迟(默认 3 秒) + final Duration? hideControlsTimer; + + /// 进度条配色(Material 风格) + final ChewieProgressColors? materialProgressColors; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: Text(title ?? '视频播放'), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: VideoPlayerView( + url: url, + title: title, + aspectRatio: aspectRatio, + autoPlay: autoPlay, + looping: looping, + muted: muted, + volume: volume, + startAt: startAt, + isLive: isLive, + showControls: showControls, + allowFullScreen: allowFullScreen, + allowPlaybackSpeedChanging: allowPlaybackSpeedChanging, + allowMuting: allowMuting, + allowDragging: allowDragging, + placeholder: placeholder, + playbackSpeeds: playbackSpeeds, + hideControlsTimer: hideControlsTimer, + materialProgressColors: materialProgressColors, + ), + ), + ), + ); + } +} diff --git a/lib/core/components/video_player_view.dart b/lib/core/components/video_player_view.dart new file mode 100644 index 0000000..62d1a98 --- /dev/null +++ b/lib/core/components/video_player_view.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; +import 'package:chewie/chewie.dart'; + +/// ============================================================ +/// 【VideoPlayerView】通用视频播放器(内嵌版,无 Scaffold/AppBar) +/// +/// 一、和 VideoPlayerPage 的关系(拆分说明): +/// - VideoPlayerView:纯播放器,可嵌进任何页面/富文本/列表卡片, +/// 自带圆角黑底 + loading/错误态 + 切后台自动暂停; +/// - VideoPlayerPage:整页 = Scaffold + VideoPlayerView, +/// 给"独立播放页"场景用。 +/// 两处共用同一套 props 和播放核心,一处改两处生效。 +/// +/// 二、props 对照表(同 VideoPlayerPage,Vue 开发者速查): +/// url ← Vue 的 src(必填) +/// title ← 标题(本组件内用于加载失败提示) +/// aspectRatio ← 宽高比(默认跟随视频本身,兜底 16:9) +/// autoPlay / looping / muted / volume / startAt / isLive +/// showControls / allowFullScreen / allowPlaybackSpeedChanging +/// allowMuting / allowDragging / placeholder / playbackSpeeds +/// hideControlsTimer / materialProgressColors +/// +/// 三、高频坑(承接 VideoPlayerPage): +/// 1. dispose 顺序不能反:先 chewie 再 video_player(反了闪退); +/// 2. await initialize() 后必须检查 mounted 再 setState; +/// 3. Web 端不支持 VideoPlayerController.file,一律传网络 URL; +/// 4. 列表页/富文本里嵌多个实例时:只让当前可见的实例播放, +/// 其余用 autoPlay: false(否则多路同时解码占满内存); +/// 5. 初始化成功后 aspectRatio 跟随视频真实比例,未成功前兜底 16:9。 +/// ============================================================ +class VideoPlayerView extends StatefulWidget { + const VideoPlayerView({ + super.key, + required this.url, + this.title, + this.aspectRatio, + this.autoPlay = false, + this.looping = false, + this.muted = false, + this.volume = 1.0, + this.startAt = Duration.zero, + this.isLive = false, + this.showControls = true, + this.allowFullScreen = true, + this.allowPlaybackSpeedChanging = true, + this.allowMuting = true, + this.allowDragging = true, + this.placeholder, + this.playbackSpeeds, + this.hideControlsTimer, + this.materialProgressColors, + }); + + /// 视频地址(http/https 直链,建议后端统一转 H.264 MP4) + final String url; + + /// 标题(加载失败提示用) + final String? title; + + /// 视频宽高比;不传 = 跟随视频本身,初始化前兜底 16:9 + final double? aspectRatio; + + /// 是否自动播放(列表/富文本内嵌默认 false,避免多路同时解码) + final bool autoPlay; + + /// 是否循环播放 + final bool looping; + + /// 初始是否静音 + final bool muted; + + /// 初始音量(0.0 ~ 1.0) + final double volume; + + /// 起始播放位置 + final Duration startAt; + + /// 直播模式:隐藏进度条/快进/倍速 + final bool isLive; + + /// 是否显示控制条 + final bool showControls; + + /// 是否允许全屏 + final bool allowFullScreen; + + /// 是否允许切换倍速 + final bool allowPlaybackSpeedChanging; + + /// 是否允许静音(控制条上的静音按钮) + final bool allowMuting; + + /// 是否允许拖拽进度 + final bool allowDragging; + + /// 封面占位图(加载完成前展示,对标 poster) + final Widget? placeholder; + + /// 倍速档位列表(默认 0.25x ~ 2.0x,传 null 用默认) + final List? playbackSpeeds; + + /// 控制条自动隐藏延迟(默认 3 秒) + final Duration? hideControlsTimer; + + /// 进度条配色(Material 风格) + final ChewieProgressColors? materialProgressColors; + + @override + State createState() => _VideoPlayerViewState(); +} + +class _VideoPlayerViewState extends State + with WidgetsBindingObserver { + /// 官方底层控制器 + VideoPlayerController? _videoController; + + /// chewie UI 控制器(初始化成功后才创建) + ChewieController? _chewieController; + + /// 是否正在初始化(loading 态) + bool _initializing = true; + + /// 初始化/播放错误信息(null = 无错误) + String? _error; + + /// 初始化成功后视频的真实宽高比(未成功前 null → 兜底 16:9) + double? _aspectRatio; + + @override + void initState() { + super.initState(); + // 监听 App 生命周期:切后台自动暂停 + WidgetsBinding.instance.addObserver(this); + _initPlayer(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + // 释放顺序不能反:先 chewie 再 video_player(反了会闪退) + _chewieController?.dispose(); + _videoController?.dispose(); + super.dispose(); + } + + /// App 切后台 / 来电 → 自动暂停,防止声音继续播 + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) { + _videoController?.pause(); + } + } + + /// 初始化播放器:解析视频头 → 设初始状态 → 建 chewie 控制器 + Future _initPlayer() async { + // 1. 创建官方底层控制器(此时还没真正加载视频) + final videoController = + VideoPlayerController.networkUrl(Uri.parse(widget.url)); + _videoController = videoController; + + try { + // 2. 必须 await 初始化,才能拿到 duration / aspectRatio + await videoController.initialize(); + + // 3. 初始静音 / 音量(muted 优先,两者互斥时静音生效) + if (widget.muted) { + await videoController.setVolume(0); + } else if (widget.volume != 1.0) { + await videoController.setVolume(widget.volume); + } + + // 异步返回后页面可能已被销毁,必须检查 mounted + if (!mounted) return; + + // 4. 用 chewie 包一层 UI 控制器(所有 props 在这里落地) + _chewieController = ChewieController( + videoPlayerController: videoController, + // 宽高比:传了用传的,没传用视频本身的 + aspectRatio: widget.aspectRatio ?? videoController.value.aspectRatio, + autoPlay: widget.autoPlay, + looping: widget.looping, + showControls: widget.showControls, + allowFullScreen: widget.allowFullScreen, + allowPlaybackSpeedChanging: widget.allowPlaybackSpeedChanging, + allowMuting: widget.allowMuting, + draggableProgressBar: widget.allowDragging, + startAt: widget.startAt, + isLive: widget.isLive, + placeholder: widget.placeholder, + playbackSpeeds: + widget.playbackSpeeds ?? const [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], + hideControlsTimer: + widget.hideControlsTimer ?? const Duration(seconds: 3), + materialProgressColors: widget.materialProgressColors, + errorBuilder: (context, errorMessage) => Center( + child: Text( + '播放出错:$errorMessage', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + ), + ); + setState(() { + _initializing = false; + _aspectRatio = videoController.value.aspectRatio; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _initializing = false; + _error = e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + // 内嵌布局:圆角黑底 + 宽高比容器(初始化后跟随视频真实比例) + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AspectRatio( + aspectRatio: widget.aspectRatio ?? _aspectRatio ?? 16 / 9, + child: ColoredBox( + color: Colors.black, + child: Center(child: _buildBody()), + ), + ), + ); + } + + /// 三种状态渲染:loading / 错误 / 播放器 + Widget _buildBody() { + if (_error != null) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.white38), + const SizedBox(height: 12), + Text( + '${widget.title != null ? '${widget.title}:' : ''}加载失败\n$_error', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + ], + ), + ); + } + if (_initializing || _chewieController == null) { + return const CircularProgressIndicator(color: Colors.white); + } + return Chewie(controller: _chewieController!); + } +} diff --git a/lib/core/config/app_brand.dart b/lib/core/config/app_brand.dart new file mode 100644 index 0000000..cad362c --- /dev/null +++ b/lib/core/config/app_brand.dart @@ -0,0 +1,90 @@ +import 'package:new_learn/core/theme/brand_theme.dart'; + +/// ============================================================ +/// 【AppBrand】品牌配置表 —— 运行时静态表(对标 uni-app 的 appList) +/// +/// 一、概念: +/// 每所学校/客户 = 一个品牌(App 一个包)。品牌决定三件事: +/// appName 桌面/标题显示的 App 名; +/// baseUrl 正式环境的专属接口地址(prod 时覆盖通用地址); +/// themeId 关联 core/theme/brand_theme.dart 的主题注册表, +/// 换品牌 = 换主题色板 + 换接口 + 换名字,一套全换。 +/// +/// 二、新增品牌(三步,复刻 uni-app appList 的加一行风格): +/// 1. 本文件 appBrands 里加一行; +/// 2. 若要用新主题,core/theme/brands/ 下建主题文件并注册; +/// 3. 构建时 --dart-define=BRAND=新id。 +/// +/// 三、与主题的关系: +/// 主题走运行时(BrandTheme,可切换/持久化); +/// 品牌走发布期(打包定死)。品牌. themeId 指定该品牌默认主题, +/// 运行时仍可在 brandThemes 里切换(互不干扰)。 +/// ============================================================ +class AppBrand { + const AppBrand({ + required this.id, + required this.appName, + this.baseUrl, + required this.themeId, + }); + + /// 品牌标识(--dart-define=BRAND= 用的值,必须与 Map key 一致) + final String id; + + /// App 显示名(桌面图标名 + MaterialApp.title) + final String appName; + + /// 正式环境专属接口地址(prod 时覆盖通用地址;null = 走环境通用地址) + final String? baseUrl; + + /// 关联主题:brand_theme.dart 注册表里 BrandTheme.name + final String themeId; +} + +/// 品牌注册表:与 uni-app appList 一一对应。 +/// themeId 用 BrandNames 常量(一个主题可对应多个品牌,改一处全同步)。 +const Map appBrands = { + 'aixue': AppBrand( + id: 'aixue', + appName: '爱学蝶变', + baseUrl: 'https://ylax-api.cqbzyx.com', + themeId: BrandNames.skyBlue, + ), + 'chucai': AppBrand( + id: 'chucai', + appName: '楚才爱学', + baseUrl: 'https://ccax-api.hbxyszzhyx.com', + themeId: BrandNames.red, + ), + 'yule': AppBrand( + id: 'yule', + appName: '渝乐爱学', + baseUrl: 'https://ylax-api.cqbzyx.com', + themeId: BrandNames.skyBlue, + ), + 'yuzhi': AppBrand( + id: 'yuzhi', + appName: '豫志爱学', + baseUrl: 'https://yzax-api.cnzhyx.cn', + themeId: BrandNames.skyBlue, + ), + 'qinxiao': AppBrand( + id: 'qinxiao', + appName: '秦骁爱学-智略行', + baseUrl: 'https://qxax-api.xatyzzhyx.com', + themeId: BrandNames.skyBlue, + ), + 'wansi': AppBrand( + id: 'wansi', + appName: '皖思爱学', + baseUrl: 'https://wsax-api.ahsdfzyx.com', + themeId: BrandNames.skyBlue, + ), + 'shucai': AppBrand( + id: 'shucai', + appName: '蜀才爱学-弘昇智', + baseUrl: 'https://scax-api.mynszxyx.com', + themeId: BrandNames.skyBlue, + ), + // 新增品牌:直接加一行即可(对标 uni-app appList 的注释示例) +}; diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart new file mode 100644 index 0000000..03c8900 --- /dev/null +++ b/lib/core/config/app_config.dart @@ -0,0 +1,49 @@ +import 'package:new_learn/core/config/app_brand.dart'; +import 'package:new_learn/core/config/app_env.dart'; +import 'package:new_learn/core/theme/brand_theme.dart'; + +/// ============================================================ +/// 【AppConfig】配置聚合层 —— 全项目唯一取配置入口 +/// +/// 组合规则(复刻 uni-app:appList × envList): +/// baseUrl = 品牌专属正式地址(prod 时覆盖) ?? 环境通用地址 +/// appName = 品牌的名字(桌面图标名 / title) +/// theme = 按品牌的 themeId 查主题注册表(现有 BrandTheme 体系) +/// +/// 消费方(Dio / TabApp / 页面)只 import 这里,不碰 AppEnv/AppBrand: +/// 1. ApiConfig.baseUrl → AppConfig.baseUrl +/// 2. TabApp theme/title → AppConfig.theme / AppConfig.appName +/// +/// 三层解耦: +/// 环境 = 接口在哪(编译期定死) 品牌 = 这是谁家的 App(发布期定死) +/// 主题 = 长什么样(运行时可切换,与上面两层互不依赖) +/// ============================================================ +class AppConfig { + AppConfig._(); + + /// 当前品牌(--dart-define=BRAND 指定;未知标识兜底回 aixue 防崩) + static AppBrand get currentBrand => + appBrands[AppEnv.brand] ?? appBrands['aixue']!; + + /// App 显示名(桌面图标名 / MaterialApp.title) + static String get appName => currentBrand.appName; + + /// 接口地址:prod 用品牌专属地址(同 uni-app 按 appType 覆盖), + /// 其他环境用环境通用地址。 + static String get baseUrl => + AppEnv.isProd ? (currentBrand.baseUrl ?? AppEnv.baseUrl) : AppEnv.baseUrl; + + /// 当前品牌对应的主题(查现有 BrandTheme 注册表) + static BrandTheme get theme { + for (final t in brandThemes) { + if (t.name == currentBrand.themeId) return t; + } + return brandThemes.first; // 未知 themeId 兜底第一个主题,防崩 + } + + /// 小游戏地址(预留,对标 envList.gameUrl) + static String get gameUrl => AppEnv.gameUrl; + + /// 学习报告地址(预留,对标 envList.reportUrl) + static String get reportUrl => AppEnv.reportUrl; +} diff --git a/lib/core/config/app_env.dart b/lib/core/config/app_env.dart new file mode 100644 index 0000000..8053391 --- /dev/null +++ b/lib/core/config/app_env.dart @@ -0,0 +1,63 @@ +/// ============================================================ +/// 【AppEnv】环境配置 —— 编译期注入(对标 uni-app 的 envList) +/// +/// 一、核心概念: +/// 环境是"打包时定死"的维度:测试包连测试地址,正式包连正式地址。 +/// 所以用 `--dart-define` 在编译期注入,代码里是 const,零运行时开销。 +/// +/// 二、怎么用: +/// flutter run --dart-define=ENV=dev +/// flutter build apk --release --dart-define=ENV=prod +/// 不传时默认 prod(日常 flutter run 不用带参数,行为不变)。 +/// +/// 三、对标 uni-app(app.config.js envList): +/// dev = 开发/本地调试 test = 测试服 prod = 正式服 +/// 每个环境一套 {baseUrl / gameUrl / reportUrl}。 +/// ============================================================ +class AppEnv { + AppEnv._(); + + /// 环境:dev / test / prod(默认 prod,日常运行零参数) + static const String env = String.fromEnvironment('ENV', defaultValue: 'prod'); + + /// 品牌标识:aixue / chucai / yule / ...(默认 aixue=爱学蝶变) + /// 品牌表见 app_brand.dart;主题/接口/App 名都随它走(AppConfig 聚合) + static const String brand = + String.fromEnvironment('BRAND', defaultValue: 'aixue'); + + /// 三套环境地址表(与 uni-app envList 完全对齐) + static const Map> _envTable = { + 'dev': { + 'baseUrl': 'https://k12-student-api-test.23544.com:16400', + 'gameUrl': 'https://cocos-test.cnzhyx.cn', + 'reportUrl': 'https://report-test.cnzhyx.cn/', + }, + 'test': { + 'baseUrl': 'https://axdb-test.cnzhyx.cn', + 'gameUrl': 'https://cocos-test.cnzhyx.cn', + 'reportUrl': 'https://report-test.cnzhyx.cn/', + }, + 'prod': { + // 正式环境的 baseUrl 在 AppConfig 里会被品牌专属地址覆盖(同 uni-app) + 'baseUrl': 'https://ylax-api.cqbzyx.com', + 'gameUrl': 'https://axdb-cocos.cnzhyx.cn/', + 'reportUrl': 'https://axdb-report.cnzhyx.cn/', + }, + }; + + /// 取当前环境某个 key 的地址(key 不存在返回空串,避免线上崩) + static String value(String key) => + _envTable[env]?[key] ?? _envTable['prod']?[key] ?? ''; + + /// 当前环境接口地址 + static String get baseUrl => value('baseUrl'); + + /// 当前环境小游戏地址(预留,对标 envList.gameUrl) + static String get gameUrl => value('gameUrl'); + + /// 当前环境学习报告地址(预留,对标 envList.reportUrl) + static String get reportUrl => value('reportUrl'); + + /// 是否正式环境(组合规则里判断用:prod 才走品牌专属地址) + static bool get isProd => env == 'prod'; +} diff --git a/lib/core/logger/app_talker.dart b/lib/core/logger/app_talker.dart new file mode 100644 index 0000000..629ca89 --- /dev/null +++ b/lib/core/logger/app_talker.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:new_learn/core/router/router_keys.dart'; + +final Talker talker = Talker(); + +class DebugTalkerBubble extends StatefulWidget { + const DebugTalkerBubble({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => _DebugTalkerBubbleState(); +} + +class _DebugTalkerBubbleState extends State { + Offset _offset = const Offset(16, 120); + + @override + Widget build(BuildContext context) { + if (kReleaseMode) return widget.child; + + return LayoutBuilder( + builder: (context, constraints) { + final maxX = constraints.maxWidth - 56; + final maxY = constraints.maxHeight - 56; + + final clamped = Offset( + _offset.dx.clamp(0.0, maxX.isFinite ? maxX : 0.0), + _offset.dy.clamp(0.0, maxY.isFinite ? maxY : 0.0), + ); + + if (clamped != _offset) { + _offset = clamped; + } + + return Stack( + children: [ + widget.child, + Positioned( + left: _offset.dx, + top: _offset.dy, + child: GestureDetector( + onPanUpdate: (details) { + setState(() { + _offset += details.delta; + }); + }, + child: FloatingActionButton( + onPressed: () { + final nav = rootNavigatorKey.currentState; + if (nav == null) return; + nav.push( + MaterialPageRoute( + builder: (_) => TalkerScreen(talker: talker), + ), + ); + }, + child: const Icon(Icons.bug_report), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/core/models/post_model.dart b/lib/core/models/post_model.dart new file mode 100644 index 0000000..c6bacb3 --- /dev/null +++ b/lib/core/models/post_model.dart @@ -0,0 +1,44 @@ +/// ============================================================ +/// 【PostModel】数据模型 —— JSON ↔ 强类型对象的桥梁 +/// TS:接口返回就是 JS 对象,interface 只做类型提示,不用转换; +// Flutter:接口返回是通用 Map 字典,**必须通过 fromJson 把 Map 转成 Dart 类实例,才能拥有强类型、点访问、编译检查**。 +/// +/// 一、为什么必须有 fromJson? +/// Dio 只会把响应 JSON 解码成 Map/List,如果页面里到处写 +/// data['title'],容易拼错、无类型提示、改字段要全局搜。 +/// 模型类把 JSON 映射成强类型对象,编译期就能发现错误。 +/// +/// 二、高频坑: +/// 1. 服务端数字类型可能不稳定(int 变 double),用 +/// (json['id'] as num?)?.toInt() 做防御性转换; +/// 2. 字段缺失会直接抛异常,生产环境给 ?? 兜底值; +/// 3. fromJson(响应→对象)和 toJson(对象→提交)必须成对维护。 +/// ============================================================ +class PostModel { + const PostModel({ + required this.id, + required this.userId, + required this.title, + required this.body, + }); + + final int id; + final int userId; + final String title; + final String body; + + /// JSON → 对象(Dio 已把 JSON 字符串解码成 Map) + factory PostModel.fromJson(Map json) { + return PostModel( + id: (json['id'] as num?)?.toInt() ?? 0, + userId: (json['userId'] as num?)?.toInt() ?? 0, + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + ); + } + + /// 对象 → JSON(用于 POST 提交给服务器) + Map toJson() { + return {'id': id, 'userId': userId, 'title': title, 'body': body}; + } +} diff --git a/lib/core/models/user_model.dart b/lib/core/models/user_model.dart new file mode 100644 index 0000000..d331b9c --- /dev/null +++ b/lib/core/models/user_model.dart @@ -0,0 +1,168 @@ +/// ============================================================ +/// 【UserModel】用户相关模型 +/// +/// CurrentUserExtension:GET /api/v1/User/GetCurrentUserExtension 的响应体, +/// 对齐 Vue 端 api/user/type.ts 的 CurrentUserExtension 全部字段(17 个)。 +/// +/// 一、为什么字段全都要: +/// 登录后要缓存这套信息(user_storage.dart 里存 JSON), +/// "我的"页 / 任务页都可能用到,缺一个就得多调一次接口。 +/// +/// 二、高频坑: +/// 1. fromJson 全部 ?? 兜底,服务端缺字段/类型漂移不会闪崩; +/// 2. 数字统一 (json['x'] as num?)?.toInt(),防 int/double 不一致; +/// 3. toJson 与 fromJson 必须成对,否则缓存/回填会丢字段。 +/// ============================================================ +class CurrentUserExtension { + const CurrentUserExtension({ + this.userId = 0, + this.userName = '', + this.userPoints = 0.0, + this.levelName = '', + this.avatar = '', + this.className = '', + this.classId = 0, + this.gradeLevel = '', + this.graduationYear = 0, + this.targetConfigId = 0, + this.targetConfigName = '', + this.userAvatarStatus = 0, + this.userAvatarReason = '', + this.isShowGuide = false, + this.isShowReport = false, + this.id = 0, + }); + + final int userId; + final String userName; + final double userPoints; + final String levelName; + final String avatar; + final String className; + final int classId; + final String gradeLevel; + final int graduationYear; + final int targetConfigId; + final String targetConfigName; + final int userAvatarStatus; + final String userAvatarReason; + final bool isShowGuide; + final bool isShowReport; + final int id; + + /// JSON → 对象(服务器响应) + /// + /// 两个兼容点(对齐接口真实返回 {code, message, data: {...}}): + /// 1. data 包装:data 存在就取 data,直接返回 {userId,...} 也支持; + /// 2. 字符串数字:后端 userId/classId 返回的是字符串 + /// ("820466383020136"),不能直接 as num,用 _toInt 统一转换。 + factory CurrentUserExtension.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return CurrentUserExtension( + userId: _toInt(payload['userId']), + userName: payload['userName'] as String? ?? '', + userPoints: _toDouble(payload['userPoints']), + levelName: payload['levelName'] as String? ?? '', + avatar: payload['avatar'] as String? ?? '', + className: payload['className'] as String? ?? '', + classId: _toInt(payload['classId']), + gradeLevel: payload['gradeLevel'] as String? ?? '', + graduationYear: _toInt(payload['graduationYear']), + targetConfigId: _toInt(payload['targetConfigId']), + targetConfigName: payload['targetConfigName'] as String? ?? '', + userAvatarStatus: _toInt(payload['userAvatarStatus']), + userAvatarReason: payload['userAvatarReason'] as String? ?? '', + isShowGuide: payload['isShowGuide'] as bool? ?? false, + isShowReport: payload['isShowReport'] as bool? ?? false, + id: _toInt(payload['id']), + ); + } + + /// 对象 → JSON(写入本地缓存) + Map toJson() { + return { + 'userId': userId, + 'userName': userName, + 'userPoints': userPoints, + 'levelName': levelName, + 'avatar': avatar, + 'className': className, + 'classId': classId, + 'gradeLevel': gradeLevel, + 'graduationYear': graduationYear, + 'targetConfigId': targetConfigId, + 'targetConfigName': targetConfigName, + 'userAvatarStatus': userAvatarStatus, + 'userAvatarReason': userAvatarReason, + 'isShowGuide': isShowGuide, + 'isShowReport': isShowReport, + 'id': id, + }; + } + + /// 局部更新(目前只用得上 avatar:头像上传成功后替换 URL) + CurrentUserExtension copyWith({String? avatar}) { + return CurrentUserExtension( + userId: userId, + userName: userName, + userPoints: userPoints, + levelName: levelName, + avatar: avatar ?? this.avatar, + className: className, + classId: classId, + gradeLevel: gradeLevel, + graduationYear: graduationYear, + targetConfigId: targetConfigId, + targetConfigName: targetConfigName, + userAvatarStatus: userAvatarStatus, + userAvatarReason: userAvatarReason, + isShowGuide: isShowGuide, + isShowReport: isShowReport, + id: id, + ); + } +} + +/// 用户积分等级(GET /api/v1/User/GetUserPointLevel,备用) +class UserPointLevel { + const UserPointLevel({ + this.userId = 0, + this.totalPoints = 0, + this.level = 0, + this.levelPoints = 0, + this.levelMaxPoints = 0, + this.levelUpPoints = 0, + }); + + final int userId; + final int totalPoints; + final int level; + final int levelPoints; + final int levelMaxPoints; + final int levelUpPoints; + + factory UserPointLevel.fromJson(Map json) { + return UserPointLevel( + userId: (json['userId'] as num?)?.toInt() ?? 0, + totalPoints: (json['totalPoints'] as num?)?.toInt() ?? 0, + level: (json['level'] as num?)?.toInt() ?? 0, + levelPoints: (json['levelPoints'] as num?)?.toInt() ?? 0, + levelMaxPoints: (json['levelMaxPoints'] as num?)?.toInt() ?? 0, + levelUpPoints: (json['levelUpPoints'] as num?)?.toInt() ?? 0, + ); + } +} + +/// 兼容"字符串数字"(后端 userId/classId 返回 "820466383020136") +int _toInt(dynamic v) { + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? 0; + return 0; +} + +/// 兼容小数积分(后端 userPoints 返回 105.5) +double _toDouble(dynamic v) { + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v) ?? 0; + return 0; +} diff --git a/lib/core/network/api_config.dart b/lib/core/network/api_config.dart new file mode 100644 index 0000000..d1aafd1 --- /dev/null +++ b/lib/core/network/api_config.dart @@ -0,0 +1,19 @@ +import 'package:new_learn/core/config/app_config.dart'; + +/// ============================================================ +/// 【ApiConfig】接口配置 —— 全项目唯一接口地址出处 +/// +/// 地址来源:品牌 × 环境 组合解析(对标 uni-app appList × envList): +/// prod 环境 → 当前品牌专属地址(如 aixue → ylax-api.cqbzyx.com) +/// 其他环境 → 环境通用地址(dev/test) +/// 具体组合规则见 core/config/app_config.dart。 +/// +/// 日常开发连测试环境,不用改代码,运行时带参数即可: +/// flutter run --dart-define=ENV=test +/// ============================================================ +class ApiConfig { + ApiConfig._(); + + /// 接口地址(品牌 × 环境 组合后的最终值) + static String get baseUrl => AppConfig.baseUrl; +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart new file mode 100644 index 0000000..58b43b3 --- /dev/null +++ b/lib/core/network/dio_client.dart @@ -0,0 +1,313 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/network/api_config.dart'; +import 'package:new_learn/core/storage/user_storage.dart'; +import 'package:talker_dio_logger/talker_dio_logger.dart'; + +/// ============================================================ +/// 【DioClient】Dio 网络请求封装(生产级单例) +/// +/// 一、为什么必须封装? +/// 裸用 Dio 时,每个页面都要重复写 baseUrl / 超时 / Token / 错误处理, +/// 项目一大会失控。封装后全项目共享【一个 Dio 实例】+【一套拦截器】。 +/// +/// 二、核心设计(4 层): +/// 1. 单例 :DioClient.instance 全局唯一,复用底层连接池; +/// 2. BaseOptions:统一 baseUrl / 超时 / 公共请求头; +/// 3. 拦截器三件套(InterceptorsWrapper): +/// - onRequest :打印请求日志 + 自动加 Authorization Token; +/// - onResponse :打印响应日志; +/// - onError :401 清 Token 跳登录 + 把 DioException 翻译成 ApiException; +/// 4. 泛型方法 :get / post,内部统一把错误转成 ApiException 再抛出, +/// UI 层只 catch 一个类型,永不接触 Dio 细节。 +/// +/// 三、Token 生命周期: +/// 登录成功 → user_storage.dart 存安全存储 + 这里写内存 accessToken; +/// 冷启动 → main() 里调 restoreToken(),把磁盘 token 回填内存, +/// 后续请求自动带 Authorization。 +/// +/// 四、高频坑(Dio 5.x 专属,别再用老教程): +/// 1. timeout 是 Duration 类型,不是旧版的 int 毫秒数; +/// 2. DioError 已改名 DioException(5.0 起),import 错了编译不过; +/// 3. 错误拦截器必须以 handler.reject() / handler.resolve() 结束, +/// 否则请求会永久挂起不返回; +/// 4. handler.reject 的参数类型是 DioException(不是任意对象), +/// 把翻译好的 ApiException 挂在 error 字段再抛给调用方; +/// 5. Android 真机请求网络必须加 INTERNET 权限(本工程 Manifest 已加)。 +/// ============================================================ +class DioClient { + // ---------- 1. 单例:整个 App 只有一个 Dio ---------- + DioClient._() + : _dio = Dio( + BaseOptions( + // 正式环境接口:统一从 ApiConfig 取,换环境只改 api_config.dart + baseUrl: ApiConfig.baseUrl, + connectTimeout: const Duration(seconds: 10), // 连接超时 + sendTimeout: const Duration(seconds: 10), // 发送超时 + receiveTimeout: const Duration(seconds: 10), // 接收超时 + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + ), + ) { + _dio.interceptors.add( + TalkerDioLogger( + talker: talker, + settings: const TalkerDioLoggerSettings( + printResponseTime: true, + hiddenHeaders: { + 'authorization', + }, + ), + ), + ); + _dio.interceptors.add(_buildInterceptors()); + } + + static final DioClient instance = DioClient._(); + + final Dio _dio; + + /// 内存中的 Token(拦截器用它拼 Authorization)。 + /// 登录成功后写这里;冷启动由 restoreToken() 从安全存储回填。 + String? accessToken; + + /// 冷启动回填 Token:从安全存储读出来放进内存。 + /// 必须在 runApp 之前 await(见 main.dart)。 + Future restoreToken() async { + accessToken = await UserStorage.instance.readToken(); + if (accessToken != null) { + debugPrint('▶ 【Token】已从安全存储恢复登录态'); + } + } + + // ---------- 2. 拦截器 ---------- + InterceptorsWrapper _buildInterceptors() { + return InterceptorsWrapper( + // ① 请求发出前:加 Token + 打印日志 + onRequest: (options, handler) { + // OSS 直传会带自己的 authorization 头(OSS4-HMAC-SHA256 签名), + // 任何形式存在 authorization 都不再加 Bearer,否则会覆盖 OSS 签名 + final hasAuth = options.headers['Authorization'] != null || + options.headers['authorization'] != null; + if (accessToken != null && !hasAuth) { + options.headers['Authorization'] = 'Bearer $accessToken'; + } + debugPrint('▶ 【请求】${options.method} ${options.uri}'); + handler.next(options); // 放行 + }, + + // ② 收到响应后:打印日志 + onResponse: (response, handler) { + debugPrint( + '◀ 【响应】${response.statusCode} ' + '${response.requestOptions.method} ${response.requestOptions.uri}', + ); + handler.next(response); // 放行 + }, + + // ③ 出错时:统一翻译成 ApiException + onError: (err, handler) { + final apiException = _mapError(err); + debugPrint( + '✖ 【请求失败】${err.requestOptions.method} ' + '${err.requestOptions.uri}\n → ${apiException.message}', + ); + // 401 未登录/登录过期:清内存 Token 并提示(真实项目在这里跳登录页) + if (err.response?.statusCode == 401) { + accessToken = null; + debugPrint('⚠ 【401】登录已过期,请重新登录'); + } + // 注意:必须以 reject 收尾,且 reject 的参数类型是 DioException。 + // 把翻译好的 ApiException 挂在 error 字段,调用方 catch 后再拆出来。 + handler.reject( + DioException( + requestOptions: err.requestOptions, + response: err.response, + type: err.type, + error: apiException, + stackTrace: err.stackTrace, + ), + ); + }, + ); + } + + /// 把 DioException 映射成带中文提示的 ApiException + /// (错误拦截器 reject 的 ApiException 会被 Dio 包进 error 字段再抛回来) + ApiException _mapError(DioException e) { + final inner = e.error; + if (inner is ApiException) return inner; // 已在拦截器翻译过,直接返回 + + final message = switch (e.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => + '网络连接超时,请稍后重试', + DioExceptionType.badResponse => + '服务器开小差了(${e.response?.statusCode})', + DioExceptionType.cancel => '请求已取消', + DioExceptionType.connectionError => '网络异常,请检查网络连接', + _ => '未知错误(${e.type.name})', + }; + return ApiException(e, message); + } + + // ---------- 3. 泛型请求方法(业务层只调这几个) ---------- + + /// GET 请求:path 为相对路径,如 '/posts/1';返回服务器 JSON 解码后的数据 + /// + /// 生产级参数: + /// - [cancelToken] :可取消请求(页面销毁 / 用户点取消时 cancel()); + /// - [onReceiveProgress]:下载进度回调(大文件/图片下载显示进度条); + /// - [retries] :失败自动重试次数(指数退避)。只对网络类错误 + /// 生效,业务错误 4xx/5xx 和主动取消都不重试(见 _canRetry); + /// - [headers] :本次请求额外/覆盖的头。典型场景:下载媒体文件 + /// 时覆盖全局 Content-Type 为 text/plain——application/json 是非简单 + /// 头,Web 跨域会触发 OPTIONS 预检,服务器不支持就 405 被拦(CORS)。 + Future get( + String path, { + Map? query, + CancelToken? cancelToken, + ProgressCallback? onReceiveProgress, + int retries = 0, + Map? headers, + }) async { + var attempt = 0; + while (true) { + try { + final resp = await _dio.get( + path, + queryParameters: query, + cancelToken: cancelToken, + onReceiveProgress: onReceiveProgress, + options: headers == null ? null : Options(headers: headers), + ); + return resp.data; + } on DioException catch (e) { + // 主动取消 / 业务错误 / 已重试满 → 直接抛(翻译成 ApiException) + if (!_canRetry(e) || attempt >= retries) { + throw _mapError(e); + } + attempt++; + // 指数退避:300ms → 600ms → 1.2s...(弱网时给服务器喘息时间) + await Future.delayed(_backoff(attempt)); + // 退避期间用户点了取消 → 下一次请求会立即抛 cancel,先主动感知 + if (cancelToken?.isCancelled ?? false) { + throw DioException( + requestOptions: e.requestOptions, + type: DioExceptionType.cancel, + error: ApiException(e, '请求已取消'), + ); + } + debugPrint('↻ 【重试】$path 第 $attempt 次(${e.type.name})'); + } + } + } + + /// POST 请求:data 为要提交的对象(Map / 模型.toJson() / FormData 上传) + /// + /// 生产级参数: + /// - [cancelToken] :可取消请求; + /// - [onSendProgress] :上传进度回调,如 onSendProgress: (sent, total); + /// - [onReceiveProgress]:下载进度回调; + /// - [retries] :POST 默认 0 = 不重试!重试会重复提交数据, + /// 有副作用风险;只有接口本身幂等时才显式传; + /// - [headers] :本次请求额外/覆盖的头(同 get 的 CORS 说明)。 + Future post( + String path, { + dynamic data, + CancelToken? cancelToken, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + int retries = 0, + Map? headers, + }) async { + var attempt = 0; + while (true) { + try { + final resp = await _dio.post( + path, + data: data, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + options: headers == null ? null : Options(headers: headers), + ); + return resp.data; + } on DioException catch (e) { + if (!_canRetry(e) || attempt >= retries) { + throw _mapError(e); + } + attempt++; + await Future.delayed(_backoff(attempt)); + if (cancelToken?.isCancelled ?? false) { + throw DioException( + requestOptions: e.requestOptions, + type: DioExceptionType.cancel, + error: ApiException(e, '请求已取消'), + ); + } + debugPrint('↻ 【重试】$path 第 $attempt 次(${e.type.name})'); + } + } + } + + /// 是否值得自动重试:只重试"网络层问题"(连不上/超时/闪断)。 + /// 主动取消(cancel)和业务错误(badResponse 4xx/5xx)都不重试, + /// 前者是用户意图,后者重试也大概率同样报错。 + bool _canRetry(DioException e) { + return switch (e.type) { + DioExceptionType.connectionError || + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => + true, + _ => false, + }; + } + + /// 指数退避延迟:300ms * 2^(attempt-1) + Duration _backoff(int attempt) => + Duration(milliseconds: 300 * (1 << (attempt - 1))); + + // ---------- 4. OSS 直传(PUT) ---------- + + /// PUT 请求:阿里云 OSS 直传专用。 + /// + /// 与 get/post 的区别: + /// 1. [url] 是【绝对地址】(https://{bucket}.{region}.aliyuncs.com/...), + /// Dio 检测到绝对 URL 会自动忽略 baseUrl; + /// 2. [data] 传文件字节(Uint8List); + /// 3. [headers] 必须传 OSS 签名头(Content-Type / authorization / x-oss-*), + /// 拦截器识别 authorization 后不会再拼 Bearer; + /// 4. 不做自动重试:大文件重传成本高,失败交给业务层决定。 + Future put( + String url, { + dynamic data, + CancelToken? cancelToken, + ProgressCallback? onSendProgress, + Map? headers, + }) async { + final resp = await _dio.put
光合作用是植物利用光能将二氧化碳和水合成有机物并释放氧气的过程。化学方程式:6CO2 + 6H2O → C6H12O6 + 6O2,面积用 x2 表示。
【解析】正确答案:B。