flutter架 构项目基础
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"chewie",
|
||||||
|
"Refreshable",
|
||||||
|
"riverpod",
|
||||||
|
"screenutil"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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
|
||||||
@@ -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 日志
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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` 快很多。
|
||||||
@@ -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 均可直接跳转。
|
||||||
@@ -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` 的"零、新手前置"章节。
|
||||||
@@ -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 频繁重建。
|
||||||
@@ -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` 换成课程接口返回的音频地址即可,组件层不用动。
|
||||||
@@ -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` 传对象,原理一致。
|
||||||
@@ -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。
|
||||||
@@ -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 够用)。
|
||||||
@@ -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)。
|
||||||
@@ -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 分层思想)。
|
||||||
@@ -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。
|
||||||
@@ -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`。
|
||||||
@@ -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` 让回车键变成"下一项",最后一项变"完成"。
|
||||||
@@ -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 做"分页错题本"。
|
||||||
@@ -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。
|
||||||
@@ -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 列表交给"创建笔记"接口——本页结构不用动。
|
||||||
@@ -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 注释)。
|
||||||
@@ -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),比如"选了一个城市"返回整个城市模型,接收侧依然按泛型拿。
|
||||||
@@ -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` 按页面层级给配色,响应式按宽度给结构 —— 两个维度正交,可自由组合。
|
||||||
@@ -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 即可。
|
||||||
@@ -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 的配置里——原理与本页一致。
|
||||||
@@ -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 这套入栈/出栈机制。
|
||||||
@@ -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。
|
||||||
@@ -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 插件——想学封装思想可以直接读它的源码。
|
||||||
@@ -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 解决"滚动结构",响应式解决"不同宽度下的结构",两者可叠加。
|
||||||
@@ -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 包的使用。
|
||||||
@@ -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 背景层 + 面板层"的组合。
|
||||||
@@ -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` 让用户可复制(本项目的日志区就用它)。
|
||||||
@@ -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` 包,本项目的轻量模型够用且无依赖。
|
||||||
@@ -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/网格思路,可对照学习。
|
||||||
|
|
||||||
@@ -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 的写入。
|
||||||
@@ -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 → 写组件 → 页面桥接。
|
||||||
@@ -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 都有配套文档。
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -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 "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |