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`; +- 富文本里的 `