flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
DEBUG_SERVER_URL=http://192.168.77.158:7777/event
DEBUG_SESSION_ID=avatar-orientation
@@ -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}
+45
View File
@@ -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
+30
View File
@@ -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'
+8
View File
@@ -0,0 +1,8 @@
{
"cSpell.words": [
"chewie",
"Refreshable",
"riverpod",
"screenutil"
]
}
View File
+28
View File
@@ -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
+14
View File
@@ -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
+92
View File
@@ -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")
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+75
View File
@@ -0,0 +1,75 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 加上这一行!网络访问权限 -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Android 13+ 通知权限(flutter_local_notifications 需要) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- 图片选择(wechat_assets_picker / wechat_camera_picker):
相机权限 + 相册读权限(Android 13+ 用 READ_MEDIA_IMAGES,
13 以下用 READ_EXTERNAL_STORAGE,用 maxSdkVersion 限一下) -->
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<!-- gal 保存到相册:Android API <= 29 需要 WRITE_EXTERNAL_STORAGE -->
<!-- tools:replace 解决与 camera_android_camerax 的 maxSdkVersion 冲突 -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29"
tools:replace="android:maxSdkVersion"/>
<application
android:label="爱学蝶变"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<!-- 2026-08-06 已确认:模拟器闪退根因是 AVD GPU 模式(software→swiftshader_indirect),
与 Impeller 无关,故 Impeller 保持默认开启。若以后在模拟器上再遇到
渲染崩溃,可取消下面这段注释(回退 Skia 渲染)再试:
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
-->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.yuanxuan.aixuediebian
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+18
View File
@@ -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
}
+24
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+16
View File
@@ -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
+5
View File
@@ -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
+25
View File
@@ -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"
+31
View File
@@ -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")
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+40
View File
@@ -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
+27
View File
@@ -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 日志
+6
View File
@@ -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
+71
View File
@@ -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` 快很多。
+114
View File
@@ -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 均可直接跳转。
+130
View File
@@ -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` 的"零、新手前置"章节。
+177
View File
@@ -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<Page> with SingleTickerProviderStateMixin {
// ② controller:驱动一切(vsync 必传,duration 定义时长)
late final AnimationController _answerCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
// ③ TweenSequence:分段动画(先放大再回弹 = 两个 Tween 按权重拼)
late final Animation<double> _correctScale = TweenSequence<double>([
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<double> _wrongShake = TweenSequence<double>([
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 频繁重建。
+118
View File
@@ -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:相当于"封装好的 `<audio-player url="...">` 组件",内部用 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`;
- 富文本里的 `<audio>` 标签会自动替换成 AudioPlayerCard,见 [rich_text_demo.md](rich_text_demo.md);
- 进阶"列表连续播放":在 AudioPlayerCard 上加 `setAudioSources` + 上一首/下一首(just_audio 原生支持);
- 真实项目:把 `_audioUrl` 换成课程接口返回的音频地址即可,组件层不用动。
+86
View File
@@ -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<String, dynamic> ? 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` 传对象,原理一致。
+129
View File
@@ -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<void> _run(Future<void> 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<void> _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。
+137
View File
@@ -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<void> _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 够用)。
+109
View File
@@ -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<int>(
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<int> _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)。
+186
View File
@@ -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<FormState> _formKey = GlobalKey<FormState>(); // 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\<bool> 校验"非输入型"字段
```dart
FormField<bool>(
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 分层思想)。
+92
View File
@@ -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。
+212
View File
@@ -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:`<img src>` 的 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);
- 富文本里的 `<img>` 渲染见 [rich_text_demo.md](rich_text_demo.md);
- 图片墙布局用 [grid_demo.md](grid_demo.md) 的 GridView;网络图片预览(双指缩放)用 `photo_view`。
+109
View File
@@ -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` 让回车键变成"下一项",最后一项变"完成"。
+119
View File
@@ -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<List<dynamic>>(
// 响应式核心:数据库变化自动推送;fireImmediately 首帧先出一份当前数据
stream: WrongQuestionStore.instance.watchAll(),
builder: (context, snapshot) {
if (snapshot.hasError) return const Text('监听出错');
final items = snapshot.data ?? const <dynamic>[];
// items 变化即重建列表
},
)
```
- `watchAll()` 返回 `Stream<List>`,页面必须用 `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<void> _addWrong() async {
final title = _titleC.text.trim();
if (title.isEmpty) { /* SnackBar 提示 */ return; }
await WrongQuestionStore.instance.add(...);
_titleC.clear(); // 清空输入框(controller 直改,无需 setState)
}
// 删除(watch 也会收到删除事件,列表自动减少)
Future<void> _delete(dynamic item) async {
final id = item.id as int;
await WrongQuestionStore.instance.remove(id);
}
// 手动查询(对比:非响应式,必须自己点才更新)
Future<void> _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 做"分页错题本"。
+144
View File
@@ -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<void> _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。
+140
View File
@@ -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<void> _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<void>.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 列表交给"创建笔记"接口——本页结构不用动。
+121
View File
@@ -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 注释)。
+107
View File
@@ -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<String>(context, MaterialPageRoute(...));
// 返回侧(本页)
Navigator.pop(context, '男');
```
- `pop(context, value)` 的值类型与 `push<String>` 的泛型对应;
- **用户没选(系统返回键 / 手势返回)→ 没调 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<String>` 对应 `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),比如"选了一个城市"返回整个城市模型,接收侧依然按泛型拿。
+118
View File
@@ -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` 按页面层级给配色,响应式按宽度给结构 —— 两个维度正交,可自由组合。
+123
View File
@@ -0,0 +1,123 @@
# 【Demo】富文本渲染(RichTextContent:HTML → 题目/解析)
> 源文件:`lib/pages/demo/rich_text_demo.dart` | 分类:组件封装
> 一句话:后端给的 HTML(题目/解析/听力材料)渲染成富文本 —— 还支持字号调节、深色模式、自定义样式。
---
## 一、这个 Demo 解决什么问题
教育 App 的题目/解析是**后端返回的 HTML 字符串**(`<h3>`、`<img>`、`<table>`、`<audio>`…),
前端要把它渲染成漂亮的可交互富文本。本页演示 `RichTextContent`(`lib/core/components/rich_text_content.dart`):
- 完整渲染:标题、粗体、**上标下标**、`<ul>` 选项、`<table>` 表格;
- `<audio>` 标签**自动替换成本项目的 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 里写 `<span class="answer">B</span>` → 渲染成红色加粗。**按 class 定制样式**,不用后端改结构。
### 3. 本地图片:asset:// 协议
```html
<img src="asset://assets/img/luola_logo.png" width="160" height="160"/>
```
`asset://` + 资源路径 = 加载本地资源图片(最稳,不受网络影响)。实际后端返回完整 URL 或相对路径(相对路径需业务层拼 baseUrl)。
### 4. audio 标签自动替换
```html
<audio src="asset://assets/audio/sample-30s.mp3" title="听力材料(30 秒)" controls></audio>
```
组件内部检测 `<audio>` → 替换成 `AudioPlayerCard`(见 [audio_player_demo.md](audio_player_demo.md))——听力题直接能播。
---
## 四、关键代码剖析
```dart
static const String _html = '''
<h3>第 1 题 · 阅读理解</h3>
<p>光合作用是植物利用光能将<strong>二氧化碳</strong>和<strong>水</strong>合成有机物并释放氧气的过程。化学方程式:6CO<sub>2</sub> + 6H<sub>2</sub>O → C<sub>6</sub>H<sub>12</sub>O<sub>6</sub> + 6O<sub>2</sub>,面积用 x<sup>2</sup> 表示。</p>
<img src="asset://assets/img/luola_logo.png" width="160" height="160"/>
<ul>
<li>A. 光合作用只发生在夜间</li>
<li>B. 光合作用会释放氧气</li>
</ul>
<table border="1" cellpadding="6">
<tr><th>条件</th><th>有光</th><th>黑暗</th></tr>
<tr><td>释放 O₂</td><td>√</td><td>×</td></tr>
</table>
<audio src="asset://assets/audio/sample-30s.mp3" title="听力材料(30 秒)" controls></audio>
<p><b>【解析】</b>正确答案:<span class="answer">B</span>。</p>
''';
```
字号控制条(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>` 替换成的播放器组件见 [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 即可。
+99
View File
@@ -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 的配置里——原理与本页一致。
+104
View File
@@ -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<String>(
context,
MaterialPageRoute(builder: (_) => const PickerPage()),
);
if (context.mounted) { // await 之后必须判 mounted!
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('你选择了:${result ?? '未选择'}')),
);
}
},
```
- 泛型 `<String>` 声明返回类型;
- 用户点系统返回键(没有 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<String>`,别让类型随意流转。
---
## 六、扩展思考
- pop 带值的一方见 [picker_page.md](picker_page.md)(`Navigator.pop(context, '男')`);
- 参数接收的两种通道见 [detail_page.md](detail_page.md);
- 真实项目推荐 go_router:声明式路由 + 守卫 + 深链,但底层仍是 Navigator 这套入栈/出栈机制。
+91
View File
@@ -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。
+150
View File
@@ -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 插件——想学封装思想可以直接读它的源码。
+114
View File
@@ -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 解决"滚动结构",响应式解决"不同宽度下的结构",两者可叠加。
+125
View File
@@ -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<double> _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 包的使用。
+119
View File
@@ -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 背景层 + 面板层"的组合。
+148
View File
@@ -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` 让用户可复制(本项目的日志区就用它)。
+128
View File
@@ -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` 包,本项目的轻量模型够用且无依赖。
+144
View File
@@ -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/网格思路,可对照学习。
+128
View File
@@ -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<String>, int)> _bank = [
('7 × 8 = ?', ['54', '56', '58', '63'], 1),
('Flutter 官方推荐的状态管理?', ['Provider', 'Riverpod', 'BLoC', 'GetX'], 1),
];
String get _question => _bank[_qIndex].$1; // 题干
List<String> get _options => _bank[_qIndex].$2; // 选项
int get _rightIndex => _bank[_qIndex].$3; // 正确答案索引
```
---
## 四、关键代码剖析
```dart
// 答题:对 → 不记录;错 → 接口上报 + 本地错题(watch 自动刷新)
Future<void> _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 的写入。
+605
View File
@@ -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:<Notifier类, 状态类型>,构造函数引用传入
final answerProvider =
NotifierProvider<AnswerNotifier, Map<int, String>>(AnswerNotifier.new);
// ② 写 Notifier 类:状态在这里定义、修改
class AnswerNotifier extends Notifier<Map<int, String>> {
@override
Map<int, String> 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, List<ExamQuestion>>(PaperNotifier.new);
class PaperNotifier extends AsyncNotifier<List<ExamQuestion>> {
@override
Future<List<ExamQuestion>> 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<List<...>>
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<ExamPage> {
@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<ExamQuestion>` | 题目(异步加载) |
| `answerProvider` | NotifierProvider | `Map<int, String>` | 答案(同步读写) |
| `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<dynamic>` 才能双端编译。
**为什么选 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<String>`,接口 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<PaperNotifier, List<ExamQuestion>>(...);
class PaperNotifier extends AsyncNotifier<List<ExamQuestion>> {
static const bool mock = true; // 接口 mock 开关
Future<List<ExamQuestion>> build() async { ... }
}
```
- **为什么 AsyncNotifierProvider**:题目从接口加载是异步的,AsyncValue 自带 loading / error / data 三态,页面 `when()` 分支渲染
- **为什么 mock 用 const bool**:对标 Vue3 内联 mock 体验,一行开关切换本地生成 / 真实接口,热重启即生效
- **为什么题目只放内存不落 Isar**:题目是"会话数据"不是"资产",每次进卷拉最新(可能有勘误);100 题 ≈ 1MB,内存无压力
#### answer_provider.dart(答案数据)
```dart
final answerProvider = NotifierProvider<AnswerNotifier, Map<int, String>>(...);
class AnswerNotifier extends Notifier<Map<int, String>> {
void answer(int questionId, String value) {
state = {...state, questionId: value}; // ① 内存主状态
unawaited(AnswerRecordStore.instance.put(...)); // ② 异步落盘(fire-and-forget)
}
void restore(Map<int, String> 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<questionId, String>` 一个结构通吃所有题型,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 → 写组件 → 页面桥接。
+712
View File
@@ -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<T>` / `Map<K,V>` / `Set<T>` |
| 类 | 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<User> 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<int>() => '整数列表',
_ => '未知',
};
```
### 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<bool> privacyAcceptedNotifier = ValueNotifier<bool>(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<CurrentUserExtension?> {
@override
Future<CurrentUserExtension?> build() async {
// 冷启动异步从本地缓存回填
return await UserStorage.instance.readUser();
}
void set(user) {
state = AsyncData(user); // 有值
authTick.value++; // 触发路由守卫重算
}
}
// 全局 Provider
final currentUserProvider =
AsyncNotifierProvider<CurrentUserNotifier, CurrentUserExtension?>(...);
```
状态四态生命周期:
```
AsyncLoading() → 正在读缓存
AsyncData(user) → 有值(已登录)
AsyncData(null) → 明确未登录
AsyncError(e) → 异常
```
> **讲解要点**:`authTick`(ValueNotifier<int>)是登录态变化的信号源,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<T>(path, {cancelToken, queryParameters})
│ ├─ post<T>(path, {data, cancelToken, onSendProgress})
│ └─ put<T>(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<String, dynamic> 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<PageLevel, PageTheme>
├─ 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<List<WrongQuestion>> 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 都有配套文档。
+36
View File
@@ -0,0 +1,36 @@
// 集成测试:真机/模拟器上跑完整 App 冒烟
//
// 与 flutter test 的区别:
// 单元/Widget 测试跑在 Dart 虚拟机里(无原生插件,靠 mock);
// 集成测试跑在【真实设备】上,插件/网络/文件系统都是真的,
// 专门验证"代码 + 原生环境"组合起来能不能正常跑。
//
// 运行(需要先插手机或开模拟器):
// flutter test integration_test -d <device-id>
// 查看设备: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);
});
}
+34
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+644
View File
@@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* 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 = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+16
View File
@@ -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)
}
}
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

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