flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/providers/user_provider.dart';
/// ============================================================
/// Align 对齐布局演示页 + Riverpod 全局用户信息展示
///
/// 一、Align 两种使用方式:
/// 1. 直接运行本页面:把 main() 改为
/// runApp(const ProviderScope(child: MaterialApp(home: AlignDemoPage())));
/// 2. 复制 body 核心代码到任意 Scaffold 的 body 中:
///
/// body: Container(
/// width: 300,
/// height: 300,
/// color: Colors.grey[200],
/// child: Align(
/// alignment: Alignment.bottomLeft, // 子组件放到父盒子左下角
/// child: Container(...), // 用户信息卡片
/// ),
/// ),
///
/// 二、Flutter 核心布局规则(务必理解):
/// 1. 父组件给子组件传递【约束】(Constraints),子组件在约束范围内决定自身【大小】;
/// 2. Align 会把自身区域扩大到父组件允许的最大范围(宽高撑满),
/// 然后通过 alignment 把子组件放到指定位置;
/// 3. 子组件的 size(宽高)如果不写,Align 会取子组件的固有尺寸;
/// 一旦写了固定 width/height,就由子组件自己决定,Align 不再干预。
///
/// 三、Riverpod 全局状态用法(本次新增重点):
/// - AlignDemoPage 改成 ConsumerWidget(build 多一个 WidgetRef ref 参数);
/// - `ref.watch(currentUserProvider)` 订阅全局用户,登录后任何页面都拿到;
/// - `.value` 是 CurrentUserExtension?:登录了有值,未登录是 null;
/// - 登录页登录成功时会 set 这个 provider,本卡片自动刷新(无需手动 setState)。
///
/// 四、高频坑:
/// 1. 用了 ref 的组件必须继承 ConsumerWidget / ConsumerStatefulWidget,
/// 普通 StatelessWidget 拿不到 ref(编译报错);
/// 2. ref.watch 要在 build 里调用(声明式依赖),ref.read 才能放回调里;
/// 3. 加了 .w/.h/.sp 的 widget 不能再用 const(尺寸是运行期算的)。
/// ============================================================
class AlignDemoPage extends ConsumerWidget {
const AlignDemoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Riverpod 全局用户:登录后全 App 共享,未登录为 null
final user = ref.watch(currentUserProvider).value;
return Scaffold(
appBar: AppBar(title: const Text('Align 对齐布局 Demo11')),
// ---------- 下方即为核心代码,可直接复制到 Scaffold body ----------
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 ?? '未登录'}',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 6.h),
Text(
'班级:${user?.className ?? '-'}',
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
SizedBox(height: 4.h),
Text(
'积分:${user?.userPoints ?? 0}',
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
],
),
),
),
),
);
}
}
+481
View File
@@ -0,0 +1,481 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【AnimationDemoPage】Flutter 动画体系 Demo(4 个区域)
///
/// 一、动画体系总览(对标 CSS):
/// 1. 隐式动画(ImplicitlyAnimatedWidget) = CSS transition:
/// 只要属性值变化,组件自己补过渡帧。如 AnimatedContainer/
/// AnimatedOpacity/AnimatedSwitcher。零 controller,最简单。
/// 2. 显式动画(AnimationController) = CSS keyframes:
/// controller 手动驱动(forward/repeat),配合 Tween 定义
/// 起始值曲线,可精确控制时长/曲线/监听状态。最灵活。
/// 3. Hero 共享元素转场 = 页面跳转时元素"飞过去"
/// 4. CustomPaint 自绘 = 用 Canvas 画任意图形
///
/// 二、为什么 Flutter 动画要区分隐式/显式:
/// - 隐式适合"状态切换"(点选变绿、展开收起)——写一行 duration 即可;
/// - 显式适合"连续/回弹/自定义曲线"(答题对错弹跳、点赞缩放)——
/// 需要 controller 控制时序;它是单方向的值驱动,方向/起点自己定。
/// 判断口诀:只需要"A 变成 B 的过渡"→ 隐式;
/// 需要"按我的节奏动起来"→ 显式 controller。
///
/// 三、核心类速查:
/// - AnimationController(vsync: this):驱动一切,durations 定义时长;
/// vsync 必须传(页面 State with SingleTickerProviderStateMixin)
/// - `Tween<double>(begin, end)`:定义 0→1 之间插值出的值
/// - CurvedAnimation / CurveTween:套曲线(easeOut/bounceOut...)
/// - AnimatedBuilder(animation, builder):监听 controller 重建子树
/// - TweenSequence:分段动画(先弹大再回弹 = 两个 Tween 按权重拼)
/// - Hero(tag, child):页面 A/B 里同 tag 的两个组件自动共享转场
///
/// 四、高频坑:
/// 1. AnimationController 必须在 dispose 里 dispose,且页面必须
/// with SingleTickerProviderStateMixin(否则报 Ticker 错误);
/// 2. 隐式动画组件一定要写 duration,漏了就瞬变没有过渡;
/// 3. AnimatedBuilder 的 builder 里别放不会变的子组件(重绘浪费),
/// 不变的子树用 child 参数缓存;
/// 4. CustomPainter 的 shouldRepaint 要正确比较参数,否则 Slider
/// 拖动时不重绘(或一直重绘)。
/// ============================================================
class AnimationDemoPage extends StatefulWidget {
const AnimationDemoPage({super.key});
@override
State<AnimationDemoPage> createState() => _AnimationDemoPageState();
}
class _AnimationDemoPageState extends State<AnimationDemoPage>
with SingleTickerProviderStateMixin {
// ==================== ① 隐式动画状态 ====================
bool _boxExpanded = false; // 方块是否展开
// ==================== ② 显式动画(答题反馈) ====================
/// 唯一 controller:驱动对/错两套动画(forward 到 1.0)
late final AnimationController _answerCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
/// 选对:放大 1.0 → 1.25 → 回弹到 1.0(TweenSequence 分段)
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);
/// 选错:水平抖动 -8 → 8 → -6 → 0
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);
static const String _question = '属性变化自动产生过渡动画,用哪个组件?';
static const List<String> _options = [
'A. AnimationController',
'B. AnimatedContainer',
'C. CustomPaint',
'D. Hero',
];
static const int _rightIndex = 1; // 正确答案 B
int? _selected; // 已选选项(null = 未选)
bool? _isRight; // 本次选择对不对
// ==================== ④ CustomPaint 进度环状态 ====================
double _ringProgress = 0.76; // 0~1,配合 Slider 拖动演示实时重绘
@override
void dispose() {
// 显式动画红线:controller 必须释放
_answerCtrl.dispose();
super.dispose();
}
/// 点击选项:记录选择并触发对应反馈动画
void _onPick(int index) {
setState(() {
_selected = index;
_isRight = index == _rightIndex;
});
_answerCtrl.forward(from: 0); // 每次从 0 重新播放
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('动画体系 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionTitle('① 隐式动画 = CSS transition'),
_buildImplicitCard(),
SizedBox(height: 20.h),
_buildSectionTitle('② 显式动画 = 答题反馈(controller + TweenSequence)'),
_buildAnswerCard(),
SizedBox(height: 20.h),
_buildSectionTitle('③ Hero 共享元素转场'),
_buildHeroCard(),
SizedBox(height: 20.h),
_buildSectionTitle('④ CustomPaint 自绘进度环'),
_buildRingCard(),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF3B82F6),
),
);
}
// ==================== ① 隐式动画卡片 ====================
/// AnimatedContainer:属性(宽/圆角/颜色)变化自动补过渡帧
Widget _buildImplicitCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('点击方块:宽 / 圆角 / 颜色 三处属性同时变,'
'AnimatedContainer 自动补过渡'),
SizedBox(height: 12.h),
Center(
child: GestureDetector(
onTap: () => setState(() => _boxExpanded = !_boxExpanded),
child: AnimatedContainer(
// 核心:duration 必须写,否则"瞬变"没有过渡
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _boxExpanded ? 200.w : 120.w,
height: 90.h,
decoration: BoxDecoration(
color: _boxExpanded
? const Color(0xFF3B82F6)
: const Color(0xFFFF9800),
borderRadius: BorderRadius.circular(_boxExpanded ? 45.r : 8.r),
),
alignment: Alignment.center,
child: Text(
_boxExpanded ? '展开了' : '点我',
style: TextStyle(fontSize: 14.sp, color: Colors.white),
),
),
),
),
],
),
);
}
// ==================== ② 显式动画卡片(答题反馈) ====================
Widget _buildAnswerCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_question,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
SizedBox(height: 12.h),
for (int i = 0; i < _options.length; i++) ...[
_buildOption(i),
if (i < _options.length - 1) SizedBox(height: 8.h),
],
SizedBox(height: 12.h),
if (_selected != null)
Text(
_isRight == true ? '✅ 回答正确' : '❌ 回答错误',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: _isRight == true
? const Color(0xFF22C55E)
: const Color(0xFFEF4444),
),
),
],
),
);
}
Widget _buildOption(int index) {
final isSelected = _selected == index;
final isRight = isSelected && _isRight == true;
final isWrong = isSelected && _isRight == false;
// 选项本体(颜色由选中/对错决定)
Widget body = Container(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
decoration: BoxDecoration(
color: isRight
? const Color(0xFFE8F7EE)
: isWrong
? const Color(0xFFFDECEA)
: Colors.white,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: isRight
? const Color(0xFF22C55E)
: isWrong
? const Color(0xFFEF4444)
: const Color(0xFFE5E7EB),
),
),
child: Row(
children: [
Icon(
isRight
? Icons.check_circle
: isWrong
? Icons.cancel
: Icons.radio_button_unchecked,
size: 18,
color: isRight
? const Color(0xFF22C55E)
: isWrong
? const Color(0xFFEF4444)
: const Color(0xFF9CA3AF),
),
SizedBox(width: 8.w),
Text(
_options[index],
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF333333),
),
),
],
),
);
// 对:用 AnimatedBuilder 包 Transform.scale 做"弹跳放大"
if (isRight) {
body = AnimatedBuilder(
animation: _correctScale,
// child 缓存不变的子树,避免 builder 反复重建文字
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),
);
}
return GestureDetector(onTap: () => _onPick(index), child: body);
}
// ==================== ③ Hero 转场卡片 ====================
Widget _buildHeroCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('点击封面 → 共享元素飞到详情页(Hero 同 tag 配对)'),
SizedBox(height: 12.h),
Center(
child: GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const _HeroDetailPage(),
),
),
child: Hero(
// 页面 A/B 中 tag 相同 → 转场时自动共享动画
tag: 'hero-demo-cover',
child: Container(
width: 120.w,
height: 72.h,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF3B82F6), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(12.r),
),
alignment: Alignment.center,
child: const Icon(Icons.school, color: Colors.white, size: 32),
),
),
),
),
],
),
);
}
// ==================== ④ CustomPaint 进度环卡片 ====================
Widget _buildRingCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('CustomPainter 画圆弧 + 居中文字(拖动滑块实时重绘)'),
SizedBox(height: 12.h),
Center(
child: SizedBox(
width: 120.w,
height: 120.w,
child: CustomPaint(
painter: _ProgressRingPainter(
progress: _ringProgress,
color: const Color(0xFF3B82F6),
),
child: Center(
child: Text(
'${(_ringProgress * 100).round()}%',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.bold,
color: const Color(0xFF333333),
),
),
),
),
),
),
SizedBox(height: 8.h),
Slider(
value: _ringProgress,
onChanged: (v) => setState(() => _ringProgress = v),
),
],
),
);
}
}
/// Hero 详情页:同 tag 的大图(转场时封面"飞"到这里的尺寸)
class _HeroDetailPage extends StatelessWidget {
const _HeroDetailPage();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Hero 详情页')),
body: Center(
child: Hero(
tag: 'hero-demo-cover',
child: Container(
width: 280.w,
height: 168.h,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF3B82F6), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(16.r),
),
alignment: Alignment.center,
child: const Icon(Icons.school, color: Colors.white, size: 64),
),
),
),
);
}
}
/// 自绘进度环:CustomPainter 在 Canvas 上画两段圆弧
class _ProgressRingPainter extends CustomPainter {
const _ProgressRingPainter({required this.progress, required this.color});
/// 进度 0.0 ~ 1.0
final double progress;
final Color color;
@override
void paint(Canvas canvas, Size size) {
// 圆环几何:内缩一个线宽,让笔画完整落在画布内
const stroke = 10.0;
final rect = Offset.zero & size;
final arcRect = rect.deflate(stroke / 2);
// 背景环(浅灰,画满 360°)
final bgPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..color = const Color(0xFFE5E7EB);
canvas.drawArc(arcRect, -0.5 * 3.14159, 2 * 3.14159, false, bgPaint);
// 前景环(从 12 点方向顺时针画 progress 的比例)
final fgPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round // 圆头端点
..color = color;
canvas.drawArc(
arcRect,
-0.5 * 3.14159, // 起点:-90° = 12 点方向
2 * 3.14159 * progress,
false,
fgPaint,
);
}
/// 滑块拖动时参数变了才重绘(返回 true = 重绘)
@override
bool shouldRepaint(_ProgressRingPainter oldDelegate) {
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/audio_player_card.dart';
/// ============================================================
/// 【AudioPlayerDemoPage】音频播放 Demo(just_audio 控制条组件)
///
/// 一、本页解决什么问题:
/// 展示 AudioPlayerCard 的用法:传 asset 即可得到一个
/// 完整的音频控制条(播放/暂停、进度拖动、倍速、循环)。
///
/// 二、音频源说明:
/// 用【本地 assets 音频】做 demo:本地文件 seek 跳播无需重新缓冲,
/// 进度条拖动秒响应(在线流 seek 会重新缓冲,见组件文档"高频坑")。
/// 以后接真实课程音频时,把 asset 换成 url 传后端在线地址即可。
///
/// 三、高频坑:
/// 1. AudioPlayerCard 只是"控制条",不是整页 —— 它设计成可嵌进
/// 任意列表/详情页(如课程详情里的音频小节);
/// 2. 切后台会自动暂停(组件内部处理),无需业务页操心;
/// 3. 若要"列表连续播放",后面在 AudioPlayerCard 上加
/// setAudioSources + 上一首/下一首即可(just_audio 原生支持);
/// 4. 网络流 seek 后会有一次"缓冲中 X%"(进度条浅色区域 = 已缓冲),
/// 这是 ExoPlayer 的正常机制,不是卡死。
/// ============================================================
class AudioPlayerDemoPage extends StatelessWidget {
const AudioPlayerDemoPage({super.key});
/// 本地测试音频(30 秒,来自 assets/audio/sample-30s.mp3)
// static const String _audioAsset = 'assets/audio/sample-30s.mp3';
/// 【临时调试】网络源:观察 seek 后进度条的"缓冲区域 + 缓冲中 X%"。
/// ⚠ 踩坑:之前用 samplelib.com —— 实测它的服务器【不支持 HTTP Range】
/// (Range 请求返回 200 而非 206),导致:第一次点进度条中间段,
/// ExoPlayer 无法字节定位 → 位置被重置回起点;第二次打开因为有系统
/// 缓存 → 看似正常(会误导排查方向!)。
/// 现用 SoundHelix(curl 实测 206 Partial Content + Accept-Ranges: bytes),
/// 服务器在境外,网络慢 = 缓冲条涨得慢看得清,正好演示缓冲机制。
/// 以后接自己的课程音频,务必先验收服务器支持 Range:
/// curl -s -D - -o NUL -H "Range: bytes=0-1023" <音频url>
/// 看到 HTTP/1.1 206 Partial Content 才算支持 seek 跳播。
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: [
// 音频控制条
// 【临时调试】切网络源看缓冲条;要回本地源时取消下面注释
// 并把上面的 url 改回 asset。
// const AudioPlayerCard(
// asset: _audioAsset,
// title: '本地测试音频(30 秒)',
// autoPlay: true,
// ),
const AudioPlayerCard(
url: _audioUrl,
title: '网络测试音频(约 6 分钟)',
autoPlay: true,
),
SizedBox(height: 20.h),
const Text(
'支持:播放/暂停、进度拖动(本地秒响应)、倍速切换、循环;\n切后台自动暂停;全部通过 props 配置。',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Color(0xFF999999)),
),
],
),
),
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 详情页
/// 独立文件 + 公开类 → 别的地方用 import 引入即可
/// 接收两种参数:构造函数参数(方式1)或命名路由 arguments(方式3)
class DetailPage extends StatelessWidget {
const DetailPage({super.key, this.name = '默认', this.age = 0});
final String name;
final int age;
@override
Widget build(BuildContext context) {
// 读取命名路由的 arguments(从 pushNamed 进来时才有)
final args = ModalRoute.of(context)?.settings.arguments;
final argMap = args is Map<String, dynamic> ? args : null;
return Scaffold(
appBar: AppBar(title: const Text('详情页')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 优先用 arguments 里的值,否则用构造函数传的值
Text(
'姓名:${argMap?['name'] ?? name}',
style: TextStyle(fontSize: 20.sp),
),
SizedBox(height: 8.h),
Text(
'年龄:${argMap?['age'] ?? age}',
style: TextStyle(fontSize: 20.sp),
),
SizedBox(height: 24.h),
// 手动 pop(AppBar 的返回箭头也是 pop)
OutlinedButton(
onPressed: () => Navigator.pop(context),
child: const Text('返回上一页'),
),
],
),
),
);
}
}
+214
View File
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/network/post_api.dart';
/// ============================================================
/// Dio 网络请求 Demo 页
///
/// 一、演示内容:
/// 1. GET 单条:PostApi.fetchPost(1) → 加载中/成功/失败三种 UI;
/// 2. GET 列表:PostApi.fetchPosts() → 展示 `List<PostModel>`;
/// 3. POST 新建:PostApi.createPost() → 展示服务器回执;
/// 4. 错误兜底:请求不存在的路径,演示统一错误提示。
///
/// 二、页面写法要点:
/// 1. 请求期间 _loading=true 显示 CircularProgressIndicator,
/// 按钮禁用,防止重复点击;
/// 2. try/catch 只 catch ApiException —— 封装层已把 DioException
/// 翻译成中文提示,页面不用关心底层细节;
/// 3. 所有 await 之后 setState 前必须 if (!mounted) return;
/// (用户可能已切走页面,context 已失效)。
/// ============================================================
class DioDemoPage extends StatefulWidget {
const DioDemoPage({super.key});
@override
State<DioDemoPage> createState() => _DioDemoPageState();
}
class _DioDemoPageState extends State<DioDemoPage> {
bool _loading = false; // 请求中
String? _error; // 错误提示
String _resultTitle = '点击上方按钮发起请求'; // 结果区标题
String _resultBody = ''; // 结果区内容
/// 统一请求入口:loading → 请求 → 成功/失败 → 结束
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);
}
}
/// 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}\n'
'title: ${post.title}\nbody: ${post.body}';
});
});
}
/// GET 帖子列表(前 5 条)
Future<void> _fetchList() async {
await _run(() async {
final posts = await PostApi.fetchPosts(userId: 1);
if (!mounted) return;
setState(() {
_resultTitle = 'GET /posts?userId=1 成功,共 ${posts.length} 条';
_resultBody = posts
.take(5)
.map((p) => '#${p.id} ${p.title}')
.join('\n');
});
});
}
/// POST 新建帖子
Future<void> _create() async {
await _run(() async {
final post = await PostApi.createPost(
title: 'Flutter 学习 Demo',
body: 'Dio 封装 POST 请求测试',
);
if (!mounted) return;
setState(() {
_resultTitle = 'POST /posts 成功(模拟创建)';
_resultBody = 'id: ${post.id}\nuserId: ${post.userId}\n'
'title: ${post.title}\nbody: ${post.body}';
});
});
}
/// 错误演示:请求不存在的接口
Future<void> _fetchError() async {
await _run(() async {
await DioClient.instance.get('/not-exist-path');
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dio 网络请求封装 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 三个操作按钮
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: _loading ? null : _fetchOne,
icon: const Icon(Icons.south_west),
label: const Text('GET 单条'),
),
FilledButton.icon(
onPressed: _loading ? null : _fetchList,
icon: const Icon(Icons.list),
label: const Text('GET 列表'),
),
FilledButton.icon(
onPressed: _loading ? null : _create,
icon: const Icon(Icons.add),
label: const Text('POST 新建'),
),
OutlinedButton.icon(
onPressed: _loading ? null : _fetchError,
icon: const Icon(Icons.warning_amber),
label: const Text('故意报错'),
),
],
),
SizedBox(height: 20.h),
// 加载中
if (_loading)
Padding(
padding: EdgeInsets.symmetric(vertical: 40.h),
child: Center(child: CircularProgressIndicator()),
),
// 错误提示
if (_error != null)
Container(
width: double.infinity,
margin: EdgeInsets.only(top: 12.h),
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: const Color(0xFFFDECEA),
borderRadius: BorderRadius.circular(8.r),
),
child: Text(
_error!,
style: const TextStyle(color: Color(0xFFD32F2F)),
),
),
// 结果展示
SizedBox(height: 12.h),
Container(
width: double.infinity,
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_resultTitle,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
SizedBox(height: 8.h),
SelectableText(
_resultBody.isEmpty ? '(暂无数据)' : _resultBody,
style: TextStyle(
fontSize: 13.sp,
height: 1.6,
color: Color(0xFF666666),
),
),
],
),
),
SizedBox(height: 12.h),
// 日志提示
Text(
'💡 请求/响应日志在控制台打印(debugPrint),'
'可在 DioClient 拦截器里查看完整 URL。',
style: TextStyle(fontSize: 12.sp, color: Color(0xFF999999)),
),
],
),
),
);
}
}
+218
View File
@@ -0,0 +1,218 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【DioProDemoPage】Dio 生产级能力 Demo(取消 / 进度 / 重试)
///
/// 一、演示三个生产级场景:
/// 1. 下载进度:onReceiveProgress 实时百分比(配合 LinearProgressIndicator);
/// 2. 请求取消:CancelToken 用户手动取消 + 页面销毁自动取消(防泄漏);
/// 3. 失败重试:网络层错误自动指数退避重试 N 次(控制台看 ↻ 日志)。
///
/// 二、为什么需要 CancelToken:
/// 页面销毁但请求还在跑 → Future 没人接,响应回调还在 setState
/// (对已销毁的 State setState 会报错/泄漏)。做法:
/// 页面持有一个 CancelToken,dispose 时 cancel(),请求立即抛
/// DioExceptionType.cancel → _mapError 翻译成"请求已取消",
/// 回调里的 setState 不会执行到。
///
/// 三、为什么重试只对"网络层错误"生效:
/// - 可重试:connectionError(断网/域名解析失败)、超时、闪断;
/// - 不可重试:主动取消(用户意图)、badResponse(4xx/5xx,
/// 重试大概率同样失败,POST 还会重复提交数据)。
/// 这是生产级的取舍,见 dio_client.dart 的 _canRetry。
///
/// 四、高频坑:
/// 1. onReceiveProgress 里 setState 前必须判 mounted(回调频率高);
/// 2. total <= 0 时(响应没有 Content-Length)进度除零,要兜底;
/// 3. CancelToken 一旦取消就不能复用,每次请求要新建;
/// 4. 取消会走 onError 分支(DioExceptionType.cancel),不是不走回调;
/// 5. Web 调试跨域(CORS):application/json 是非简单头,跨域 GET 会先
/// 发 OPTIONS 预检,静态服务器不支持就 405 被拦。下载媒体/图片时
/// 用 headers 覆盖成 text/plain 简单头即可绕过(真机无此问题)。
/// ============================================================
class DioProDemoPage extends StatefulWidget {
const DioProDemoPage({super.key});
@override
State<DioProDemoPage> createState() => _DioProDemoPageState();
}
class _DioProDemoPageState extends State<DioProDemoPage> {
/// 大文件地址(约 2MB,用来让进度条动起来;绝对 URL 会覆盖 baseUrl)
static const String _bigUrl =
'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4';
CancelToken? _token; // 当前下载的取消凭证
double _progress = 0; // 0.0 ~ 1.0(未开始为 0)
bool _downloading = false; // 下载中
bool _retryEnabled = true; // 重试开关
String _log = ''; // 操作日志
@override
void dispose() {
// 页面销毁 → 自动取消在途请求(核心防泄漏点)
_token?.cancel('页面已销毁');
super.dispose();
}
/// 开始下载:带进度回调 + 可取消 + 可重试
Future<void> _startDownload() async {
if (_downloading) return;
final token = CancelToken(); // 每个请求新建一个(取消后不可复用)
_token = token;
setState(() {
_downloading = true;
_progress = 0;
_log = '';
});
try {
await DioClient.instance.get(
_bigUrl,
cancelToken: token,
// Web 调试必加!全局头是 application/json(非简单头),跨域 GET
// 会先发 OPTIONS 预检,flutter.github.io 的静态服务器不支持预检
// (返回 405)→ 被浏览器 CORS 拦截。改成 text/plain 简单头后
// 不再预检,直接放行(源已带 Access-Control-Allow-Origin: *)。
headers: const {'Content-Type': 'text/plain'},
// 下载进度回调:(已接收字节, 总字节)。total<=0 时兜底成 0
onReceiveProgress: (received, total) {
if (!mounted || token.isCancelled) return;
setState(() => _progress = total <= 0 ? 0 : received / total);
},
retries: _retryEnabled ? 2 : 0,
);
if (!mounted) return;
setState(() {
_log += '✅ 下载完成,共 ${(_progress * 100).round()}%\n';
_downloading = false;
});
} on ApiException catch (e) {
// 取消也会走到这里("请求已取消"),统一复位 UI
if (!mounted) return;
setState(() {
_log += '✖ ${e.message}\n';
_downloading = false;
});
}
}
/// 用户手动取消当前下载
void _cancelDownload() {
_token?.cancel('用户点击取消');
setState(() => _log += '⏹ 已发起取消,等待请求中断...\n');
}
/// 重试演示:请求不存在的域名 → DNS 解析失败(connectionError)
/// → 自动重试 N 次(指数退避)→ 抛"网络异常"
Future<void> _testRetry() async {
if (_downloading) return;
final stopwatch = Stopwatch()..start();
setState(() => _log = '');
try {
await DioClient.instance.get(
'https://no-such-domain-abcxyz.com/api/test',
retries: _retryEnabled ? 3 : 0,
);
} on ApiException catch (e) {
if (!mounted) return;
stopwatch.stop();
setState(() {
_log = '✖ ${e.message}\n'
'⏱ 共耗时 ${stopwatch.elapsed.inMilliseconds}ms\n'
'(重试开关:${_retryEnabled ? '开' : '关'})\n'
'📋 看控制台日志:重试开时有 4 行请求 + 3 行 ↻ 重试';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dio 生产级能力 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 重试开关
SwitchListTile(
value: _retryEnabled,
onChanged: (v) => setState(() => _retryEnabled = v),
title: const Text('失败自动重试(指数退避)'),
subtitle: const Text('重试开 → 下载重试 2 次 / 错误域名重试 3 次'),
contentPadding: EdgeInsets.zero,
),
SizedBox(height: 8.h),
// 操作按钮
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: _downloading ? null : _startDownload,
icon: const Icon(Icons.download),
label: const Text('开始下载'),
),
OutlinedButton.icon(
onPressed: _downloading ? _cancelDownload : null,
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('取消下载'),
),
OutlinedButton.icon(
onPressed: _downloading ? null : _testRetry,
icon: const Icon(Icons.refresh),
label: const Text('重试演示'),
),
],
),
SizedBox(height: 20.h),
// 下载进度
Text(
'下载进度:${(_progress * 100).round()}%',
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF666666)),
),
SizedBox(height: 8.h),
LinearProgressIndicator(
value: _progress,
minHeight: 8,
borderRadius: BorderRadius.circular(4.r),
backgroundColor: const Color(0xFFE3E8EF),
color: const Color(0xFF3B82F6),
),
SizedBox(height: 20.h),
// 日志区
Container(
width: double.infinity,
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: SelectableText(
_log.isEmpty ? '(操作日志,如:✅ 下载完成 / ✖ 网络异常)' : _log,
style: TextStyle(
fontSize: 13.sp,
height: 1.6,
color: const Color(0xFF666666),
),
),
),
SizedBox(height: 12.h),
Text(
'💡 上传进度用法:DioClient.post(path, data: FormData.fromMap({...}), '
'onSendProgress: (sent, total) {...}) —— 笔记图片上传的下一步就接它。',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
],
),
),
);
}
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/features/exam/exam_page.dart';
import 'package:new_learn/features/exam/paper_provider.dart';
/// ============================================================
/// 【ExamDemoPage】试卷练习 Demo 入口 —— 大数据滑动验证页
///
/// 一、本 Demo 验证什么(对标 Vue3 swiper 性能瓶颈):
/// PageView.builder 懒加载:100 道题只构建视口 ±1 页,
/// 滑出的页面 Element 自动回收 → 大数组不卡;
/// 同时验证题型分发(单选/多选/判断/解答)与续答持久化。
///
/// 二、演示要点:
/// 1. 选择题目数量(50/100/200)→ 开始练习;
/// 2. 快速左右滑动感受流畅度(allowImplicitScrolling 预构建);
/// 3. 答几题后退出页面 → 重新进入 → SnackBar 提示恢复进度;
/// 4. 交卷 → 自动判分(客观题)+ 清理本地进度。
///
/// 三、续答持久化说明:
/// 答案主状态在 Riverpod(内存),Isar AnswerRecord 是备份层;
/// Web 调试时 store 为空实现 → 自动降级为纯内存(不落盘)。
/// ============================================================
class ExamDemoPage extends ConsumerWidget {
const ExamDemoPage({super.key});
static const List<int> _sizes = [50, 100, 200];
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(paperSizeProvider);
return Scaffold(
appBar: AppBar(title: const Text('试卷练习 Demo(大数据滑动)')),
body: ListView(
padding: EdgeInsets.all(16.w),
children: [
_buildTipCard(),
SizedBox(height: 16.h),
_buildSizeCard(context, ref, size),
SizedBox(height: 16.h),
FilledButton.icon(
style: FilledButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 14.h),
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ExamPage()),
);
},
icon: const Icon(Icons.play_arrow),
label: Text(
'开始练习($size 题)',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
SizedBox(height: 16.h),
const Text(
'架构要点',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF3B82F6),
),
),
SizedBox(height: 8.h),
const _ArchPoint(
icon: Icons.bolt,
title: 'PageView.builder 懒加载',
desc: '只构建视口 ±1 页,滑出即回收;allowImplicitScrolling 预构建相邻页,'
'100 题滑动不掉帧 —— 规避 Vue3 swiper 全量 DOM 挂载的瓶颈。',
),
SizedBox(height: 10.h),
const _ArchPoint(
icon: Icons.account_tree_outlined,
title: '题型分发(QuestionCard)',
desc: '按 question.type switch 到单选/多选/判断/解答视图;'
'组件纯展示不碰 provider,ExamPage 唯一桥接 Riverpod。',
),
SizedBox(height: 10.h),
const _ArchPoint(
icon: Icons.save_outlined,
title: '续答持久化(Riverpod + Isar)',
desc: '答案主状态在内存,每次作答异步写 AnswerRecord(Isar);'
'退出重进自动恢复进度,交卷成功后按 paperId 清理。',
),
],
),
);
}
Widget _buildTipCard() {
return Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(12.r),
),
child: const Text(
'模拟真实试卷:100 题含单选/多选/判断/解答四种题型,'
'题干为 HTML 富文本(RichTextContent 渲染)。'
'快速滑动体验大数据流畅度;答几题后退出一键返回,重新进入可恢复进度。',
style: TextStyle(fontSize: 13, height: 1.6, color: Color(0xFF3B82F6)),
),
);
}
Widget _buildSizeCard(BuildContext context, WidgetRef ref, int size) {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'试卷题目数量(大数据规模)',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
SizedBox(height: 12.h),
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);
},
),
],
),
);
}
}
/// 架构要点行(图标 + 标题 + 描述)
class _ArchPoint extends StatelessWidget {
const _ArchPoint({
required this.icon,
required this.title,
required this.desc,
});
final IconData icon;
final String title;
final String desc;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: const Color(0xFF3B82F6)),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
),
SizedBox(height: 4.h),
Text(
desc,
style: TextStyle(
fontSize: 12.sp,
height: 1.6,
color: const Color(0xFF666666),
),
),
],
),
),
],
),
);
}
}
+357
View File
@@ -0,0 +1,357 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【FormDemoPage】表单校验 Demo(Form + TextFormField + validator)
///
/// 一、和 Vue 表单校验的对照(关键差异):
/// Vue: v-model 双向绑定 + rules 规则对象 + validate()(async-validator)
/// Flutter: TextEditingController 持有值(无 v-model)+
/// 每个 TextFormField 自带 validator 函数 +
/// formKey.currentState.validate() 统一触发
/// validator 是【同步函数】,返回 String?:
/// null → 校验通过;非 null → 错误文本(自动显示在输入框下方红字)
/// —— 所以 Flutter 没有"规则对象",每个字段的规则就写在它自己的
/// validator 里,就近维护,一眼能看到这个字段的所有规则。
///
/// 二、三种校验时机(AutovalidateMode):
/// - disabled(默认) → 只在点提交 validate() 时校验
/// - always → build 时立刻校验(空表单会满屏报错,慎用)
/// - onUserInteraction → 用户碰过该字段后才校验(推荐:
/// 未输入不报错,输错即时提示,提交时再兜底校验一次)
///
/// 三、本页演示的 7 个知识点:
/// 1. 必填/长度校验(用户名 2-8 位)
/// 2. 正则校验(手机号 / 邮箱)
/// 3. 密码强度(必须同时含字母和数字)
/// 4. 跨字段对比(确认密码 vs 密码,validator 里读另一个 controller)
/// 5. FormField<bool> 校验非输入型字段(协议勾选,errorText 手动渲染)
/// 6. 两段式校验:validate() 同步通过后,再模拟异步查重
/// (validator 不能 await,异步业务校验必须放提交后)
/// 7. 防重复提交(_submitting 置灰按钮)+ 重置表单
///
/// 四、高频坑:
/// 1. formState.reset() 只清【校验错误】和 FormField 状态,
/// 不碰 controller.text —— 清空输入要手动 clear();
/// 2. 确认密码的 validator 依赖 _pwdC.text,两个 controller 都必须
/// 在 dispose 里释放,否则内存泄漏;
/// 3. 手机号正则要用 ^...$ 全匹配,不然 11 位之后还能续数字;
/// 4. 加了 .w/.h/.sp 的 widget 不能再用 const(运行时才算尺寸);
/// 5. 异步(await)之后用 context 必须先用 mounted 判空。
/// ============================================================
class FormDemoPage extends StatefulWidget {
const FormDemoPage({super.key});
@override
State<FormDemoPage> createState() => _FormDemoPageState();
}
class _FormDemoPageState extends State<FormDemoPage> {
/// Form 的唯一钥匙:提交(validate)/ 重置(reset)都靠它
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
/// 每个输入框一个 controller(dispose 里必须释放)
final TextEditingController _nameC = TextEditingController();
final TextEditingController _phoneC = TextEditingController();
final TextEditingController _emailC = TextEditingController();
final TextEditingController _pwdC = TextEditingController();
final TextEditingController _pwd2C = TextEditingController();
bool _showPwd = false; // 密码明文/密文
bool _agreed = false; // 协议是否勾选(FormField 外部镜像,重置用)
bool _submitting = false; // 提交中(防重复提交)
@override
void dispose() {
_nameC.dispose();
_phoneC.dispose();
_emailC.dispose();
_pwdC.dispose();
_pwd2C.dispose();
super.dispose();
}
// ==================== 校验规则(每个字段一个 validator) ====================
/// 用户名:必填 + 2-8 位
String? _validateName(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入用户名';
if (s.length < 2 || s.length > 8) return '用户名需 2-8 个字符';
return null;
}
/// 手机号:必填 + 正则全匹配(^...$)
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;
}
/// 邮箱:选填,填了才校验格式
String? _validateEmail(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return null;
if (!RegExp(r'^[\w.+-]+@[\w-]+\.[\w.-]+$').hasMatch(s)) return '邮箱格式不正确';
return null;
}
/// 密码:必填 + 6-20 位 + 同时含字母和数字
String? _validatePwd(String? v) {
final s = v ?? '';
if (s.isEmpty) return '请输入密码';
if (s.length < 6 || s.length > 20) return '密码需 6-20 位';
if (!RegExp(r'(?=.*[A-Za-z])(?=.*\d)').hasMatch(s)) return '密码需同时包含字母和数字';
return null;
}
/// 确认密码:必填 + 跨字段对比(读 _pwdC.text)
String? _validatePwd2(String? v) {
final s = v ?? '';
if (s.isEmpty) return '请再次输入密码';
if (s != _pwdC.text) return '两次密码不一致';
return null;
}
// ==================== 提交 / 重置 ====================
/// 两段式校验:先同步 validate(),通过后模拟异步查重
Future<void> _submit() async {
if (_submitting) return; // 防连点
FocusScope.of(context).unfocus(); // 收起键盘
// ① 同步校验:所有子字段的 validator 逐个执行,只要有一个返回
// 非 null → validate() 为 false,错误红字已自动显示。
if (!_formKey.currentState!.validate()) {
_toast('有字段未通过校验,请检查红字提示');
return;
}
// ② 同步通过 → 异步业务校验(查重/验证码)。validator 是同步函数
// 不能 await,异步校验必须在 validate() 之后单独做。
setState(() => _submitting = true);
await Future.delayed(const Duration(seconds: 1)); // 模拟查重请求
if (!mounted) return; // 异步后必须判 mounted 才能用 context
setState(() => _submitting = false);
_toast('校验全部通过,注册成功!');
}
/// 重置:formState.reset() 只清校验错误,输入内容要手动 clear()
void _reset() {
_formKey.currentState?.reset();
_nameC.clear();
_phoneC.clear();
_emailC.clear();
_pwdC.clear();
_pwd2C.clear();
setState(() => _agreed = false);
}
void _toast(String msg) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
);
}
// ==================== UI ====================
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('表单校验 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(20.w),
child: Form(
key: _formKey,
// onUserInteraction:用户碰过的字段才即时校验,未碰不报错
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTipCard(),
SizedBox(height: 16.h),
_buildField(
label: '用户名',
hint: '2-8 个字符',
icon: Icons.person_outline,
controller: _nameC,
validator: _validateName,
),
SizedBox(height: 16.h),
_buildField(
label: '手机号',
hint: '11 位大陆手机号',
icon: Icons.phone_iphone,
controller: _phoneC,
validator: _validatePhone,
keyboardType: TextInputType.phone,
),
SizedBox(height: 16.h),
_buildField(
label: '邮箱(选填)',
hint: '例:name@example.com',
icon: Icons.mail_outline,
controller: _emailC,
validator: _validateEmail,
keyboardType: TextInputType.emailAddress,
),
SizedBox(height: 16.h),
// 密码:带"眼睛"显隐切换
TextFormField(
controller: _pwdC,
obscureText: !_showPwd,
validator: _validatePwd,
decoration: InputDecoration(
labelText: '密码',
hintText: '6-20 位,含字母和数字',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_showPwd ? Icons.visibility_outlined : Icons.visibility_off_outlined,
),
onPressed: () => setState(() => _showPwd = !_showPwd),
),
border: const OutlineInputBorder(),
),
),
SizedBox(height: 16.h),
_buildField(
label: '确认密码',
hint: '再次输入密码',
icon: Icons.lock_outline,
controller: _pwd2C,
validator: _validatePwd2,
),
SizedBox(height: 16.h),
_buildAgreement(),
SizedBox(height: 24.h),
_buildButtons(),
],
),
),
),
);
}
/// 顶部说明卡片
Widget _buildTipCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(12.r),
),
child: const Text(
'校验机制:Form 收集所有子字段的 validator,'
'点提交统一 validate()。validator 返回 null=通过,'
'返回字符串=错误红字(自动显示)。autovalidateMode 控制即时校验。',
style: TextStyle(fontSize: 13, color: Color(0xFF3B82F6)),
),
);
}
/// 通用输入框(label/hint/图标/校验规则全部参数化)
Widget _buildField({
required String label,
required String hint,
required IconData icon,
required TextEditingController controller,
required String? Function(String?) validator,
TextInputType? keyboardType,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: Icon(icon),
border: const OutlineInputBorder(),
),
);
}
/// 协议勾选:FormField<bool> 校验"非输入型"字段。
/// 注意:自定义 FormField 的 errorText 要自己在 builder 里渲染。
Widget _buildAgreement() {
return FormField<bool>(
initialValue: _agreed,
validator: (v) => (v ?? false) ? null : '请先勾选同意协议',
builder: (field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
field.didChange(!(field.value ?? false)); // 更新 FormField 自身
setState(() => _agreed = field.value ?? false); // 同步外部镜像
},
child: Row(
children: [
Icon(
_agreed
? Icons.check_circle_rounded
: Icons.radio_button_unchecked,
size: 24,
color: _agreed
? const Color(0xFF3B82F6)
: const Color(0xFFC0C4CC),
),
SizedBox(width: 8.w),
const Text('我已阅读并同意《用户协议》和《隐私政策》'),
],
),
),
if (field.hasError)
Padding(
padding: EdgeInsets.only(top: 4.h),
child: Text(
field.errorText!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
],
);
},
);
}
/// 提交 + 重置按钮
Widget _buildButtons() {
return Row(
children: [
Expanded(
child: SizedBox(
height: 48.h,
child: FilledButton(
// 提交中禁用:防重复提交
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: const Text('提交'),
),
),
),
SizedBox(width: 12.w),
Expanded(
child: SizedBox(
height: 48.h,
child: OutlinedButton(
onPressed: _submitting ? null : _reset,
child: const Text('重置'),
),
),
),
],
);
}
}
+167
View File
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// GridView 网格布局演示页
///
/// 一、两种使用方式:
/// 1. 直接运行本页面:runApp(const MaterialApp(home: GridDemoPage()))
/// 2. 复制某段核心代码到任意 Scaffold 的 body 中。
///
/// 二、三个常用构造:
/// GridView.count : 固定列数,写死 children(一次性全部创建)
/// GridView.builder : 懒加载,itemCount + itemBuilder,大量数据首选
/// GridView.extent : 按"每项最大宽度"自适应列数(屏幕越宽列越多)
///
/// 三、核心参数:
/// crossAxisCount : 列数(GridView.count 专用)
/// maxCrossAxisExtent: 每项最大宽度(GridView.extent 专用)
/// mainAxisSpacing : 主轴(上下行)间距
/// crossAxisSpacing : 交叉轴(左右列)间距
/// childAspectRatio : 每项 宽:高 比例,默认 1(正方形)
///
/// 四、高频坑:
/// 1. GridView 放 Column 必须 SizedBox 定高或 Expanded(同 ListView);
/// 2. childAspectRatio 太小(如 0.5)时文字会被挤压溢出,调比例或缩小字号。
///
/// 五、和 CustomScrollView(Sliver) 的本质区别(重点记忆):
/// GridView = CustomScrollView + 1 个 SliverGrid(框架内部自动包的)。
/// SDK 源码:GridView extends BoxScrollView,而 ScrollView.build()
/// 返回的就是 CustomScrollView——所以 GridView 是"只有网格这一种内容"
/// 的简化套餐,两者都是"单个滚动 + 懒加载"。
/// · 页面只有网格 → body: GridView.builder(...) 直接用
/// · 页面要混排 AppBar + 网格 + 列表 → CustomScrollView + slivers
/// · 本页外层 SingleChildScrollView 只是"展示台":为并排演示 3 种构造,
/// 内部塞的是固定高 GridView(嵌套滚动)。真实网格页别学这个外层!
/// ============================================================
class GridDemoPage extends StatelessWidget {
const GridDemoPage({super.key});
// 网格配色(循环使用)
static const List<Color> _colors = [
Color(0xFF3B82F6), Color(0xFF8E6FE8), Color(0xFFF05252),
Color(0xFFFF9800), Color(0xFF4CAF50), Color(0xFF00BCD4),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('GridView 网格 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _SectionTitle('Demo1:GridView.count 固定 3 列'),
Text(
'写死 children,一次性全创建,适合少量固定网格',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
SizedBox(
height: 260.h,
child: GridView.count(
crossAxisCount: 3, // 列数
mainAxisSpacing: 8.w, // 上下间距
crossAxisSpacing: 8.w, // 左右间距
childAspectRatio: 1, // 宽:高 = 1:1 正方形
children: [
for (int i = 0; i < 12; i++) _GridItem(index: i),
],
),
),
SizedBox(height: 32.h),
const _SectionTitle('Demo2:GridView.builder 懒加载'),
Text(
'itemCount: 100,只创建可见的格子,滚到哪建到哪',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
SizedBox(
height: 260.h,
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, // 4 列小格子
mainAxisSpacing: 8.w,
crossAxisSpacing: 8.w,
childAspectRatio: 1,
),
itemCount: 100, // 假装 100 个格子
itemBuilder: (context, index) => _GridItem(index: index),
),
),
SizedBox(height: 32.h),
const _SectionTitle('Demo3:GridView.extent 自适应列宽'),
Text(
'maxCrossAxisExtent: 120 → 每项最多 120 宽,屏幕越宽列数自动变多',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
SizedBox(
height: 260.h,
child: 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),
],
),
),
],
),
),
);
}
}
/// 单个网格项:彩色方块 + 序号
class _GridItem extends StatelessWidget {
const _GridItem({required this.index});
final int index;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: GridDemoPage._colors[index % GridDemoPage._colors.length],
borderRadius: BorderRadius.circular(8.r),
),
alignment: Alignment.center,
child: Text(
'$index',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
);
}
}
/// 区块标题
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: 4.h),
child: Text(
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
);
}
}
+225
View File
@@ -0,0 +1,225 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// Image 图片组件演示页
///
/// 一、两种使用方式:
/// 1. 直接运行本页面:runApp(const MaterialApp(home: ImageDemoPage()))
/// 2. 复制某段核心代码到任意 Scaffold 的 body 中。
///
/// 二、加载图片来源的几种方式:
/// CachedNetworkImage : 网络图片 + 本地缓存(生产推荐,二次加载秒出)
/// Image.network : 网络图片(无缓存,每次重新下载,仅作对比)
/// Image.asset : 本地资源图片(必须先注册 pubspec.yaml 的 assets)
/// Image.file : 本地磁盘文件(需要 path_provider,本页不演示)
/// Image.memory : 内存字节流(base64 等)
///
/// 三、BoxFit 裁切模式速记:
/// cover : 等比缩放填满并裁掉多余(最常用,头像/封面)
/// contain : 等比缩放完整显示(会有留白)
/// fill : 拉伸填满(会变形)
/// fitWidth / fitHeight : 按宽/高撑满
///
/// 四、cached_network_image(3.4.1)核心价值:
/// 1. 自动缓存:下载的图片写入本地缓存目录,二次加载直接读本地(不走网络),
/// 对标 Web 端浏览器 HTTP 缓存;
/// 2. 三段式回调:placeholder(加载中占位)/ progressIndicatorBuilder(下载进度)
/// / errorWidget(失败兜底)—— 比 Image.network 的 loadingBuilder 更可靠;
/// 3. 头像场景用 CachedNetworkImageProvider(url) 作为 ImageProvider,
/// 同样走缓存;
/// 4. 全平台含 Web(Web 端缓存能力有限,仅内存缓存,原生端走磁盘缓存)。
///
/// 五、Flutter 3.x 注意:
/// Android release 包需要在 AndroidManifest.xml 加 INTERNET 权限(已加);
/// 网络图片务必给 errorWidget 兜底,否则加载失败会抛异常白屏。
/// ============================================================
class ImageDemoPage extends StatelessWidget {
const ImageDemoPage({super.key});
// 网络图片地址(示例)
static const String _netUrl =
'https://hx.23544.com/StaticResource/2026-07-29/ranking3Position1785292048369.png';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Image 图片组件 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _SectionTitle('① 网络图片 CachedNetworkImage(带缓存 + 下载进度)'),
Text(
'progressIndicatorBuilder 显示下载进度;二次加载读本地缓存秒出',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
CachedNetworkImage(
imageUrl: _netUrl,
width: 300.w,
height: 180.h,
fit: BoxFit.cover,
// 下载进度占位(与 placeholder 二选一,progress 更直观)
progressIndicatorBuilder: (context, url, downloadProgress) =>
_buildLoadingBox(
width: 300.w,
height: 180.h,
progress: downloadProgress.progress,
),
// 兜底:加载失败(断网/404/格式错)时显示占位,不白屏
errorWidget: (context, url, error) =>
_buildErrorBox(width: 300.w, height: 180.h),
),
SizedBox(height: 32.h),
const _SectionTitle('② 本地资源图片 Image.asset'),
Text(
'路径必须与 pubspec.yaml 注册的 assets 一致',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
Image.asset(
'assets/luola_logo.png',
width: 150.w,
height: 150.h,
fit: BoxFit.contain, // 等比完整显示,不裁切
),
SizedBox(height: 32.h),
const _SectionTitle('③ 圆角网络图片 ClipRRect + CachedNetworkImage'),
Text(
'ClipRRect 把图片裁成圆角矩形;CachedNetworkImage 走缓存(图片本身没变,只是画出来带圆角)',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
ClipRRect(
borderRadius: BorderRadius.circular(60.r),
child: CachedNetworkImage(
imageUrl: _netUrl,
width: 300.w,
height: 180.h,
fit: BoxFit.cover,
// 加载中占位(转圈)
placeholder: (context, url) => _buildLoadingBox(
width: 300.w,
height: 180.h,
progress: null,
),
errorWidget: (context, url, error) =>
_buildErrorBox(width: 300.w, height: 180.h),
),
),
SizedBox(height: 32.h),
const _SectionTitle(
'④ 圆形网络头像 CircleAvatar + CachedNetworkImageProvider'),
Text(
'CachedNetworkImageProvider 走缓存;CircleAvatar 自带圆形裁剪,常做用户头像',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
CircleAvatar(
radius: 30.w,
backgroundColor: const Color(0xFFF0F2F5),
backgroundImage: CachedNetworkImageProvider(_netUrl),
// 头像加载失败时的兜底:显示首字母
onBackgroundImageError: (error, stackTrace) {},
child: Text(
'头',
style: TextStyle(fontSize: 24.sp, color: Color(0xFF999999)),
),
),
],
),
),
);
}
/// 加载中占位
/// 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)),
),
],
),
),
);
}
}
/// 区块标题
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: 4.h),
child: Text(
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// TextField 输入框演示页(登录表单)
///
/// 一、两种使用方式:
/// 1. 直接运行本页面:runApp(const MaterialApp(home: InputDemoPage()))
/// 2. 复制 body 核心代码到任意 Scaffold 的 body 中。
///
/// 二、核心知识点:
/// 1. TextEditingController 管理输入框内容,读取用 controller.text;
/// 2. 控制器必须在 dispose() 里释放,否则内存泄漏;
/// 3. obscureText 控制密码明文/密文(配合 setState 做"眼睛"切换);
/// 4. 注意:Scaffold 没有 padding 参数!外层间距要用 body 里的 Padding。
/// ============================================================
class InputDemoPage extends StatefulWidget {
const InputDemoPage({super.key});
@override
State<InputDemoPage> createState() => _InputDemoPageState();
}
class _InputDemoPageState extends State<InputDemoPage> {
// 控制器:负责管理输入框内容
final TextEditingController _userCtrl = TextEditingController();
final TextEditingController _pwdCtrl = TextEditingController();
// 密码是否明文显示(默认密文)
bool _showPwd = false;
@override
void dispose() {
// 释放控制器,防止内存泄漏
_userCtrl.dispose();
_pwdCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('TextField 登录表单 Demo')),
body: Padding(
// Scaffold 没有 padding 参数,间距写在 body 的 Padding 上
padding: EdgeInsets.all(20.w),
child: Column(
children: [
TextField(
controller: _userCtrl,
keyboardType: TextInputType.phone, // 弹数字键盘
decoration: const InputDecoration(
labelText: "手机号",
hintText: "请输入11位手机号",
prefixIcon: Icon(Icons.phone),
border: OutlineInputBorder(),
),
),
SizedBox(height: 16.h),
TextField(
controller: _pwdCtrl,
obscureText: !_showPwd, // 密文/明文由状态决定
decoration: InputDecoration(
labelText: "密码",
hintText: "请输入密码",
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
// suffixIcon:右侧小眼睛,点击切换明文
suffixIcon: IconButton(
icon: Icon(_showPwd ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_showPwd = !_showPwd; // 修改状态触发重建
});
},
),
),
),
SizedBox(height: 24.h),
ElevatedButton(
onPressed: () {
// 获取输入内容
String user = _userCtrl.text;
String pwd = _pwdCtrl.text;
if (kDebugMode) {
print("账号:$user,密码11:$pwd");
}
},
child: const Text("登录"),
),
],
),
),
);
}
}
+260
View File
@@ -0,0 +1,260 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/storage/stores/wrong_question_store.dart';
/// ============================================================
/// 【IsarWatchDemoPage】Isar 深水区:watch 响应式(错题本 Demo)
///
/// 一、watch 是什么(对比"浅水区"的主动查询):
/// 浅水区:findAll() 主动查库,数据变了要自己再查/刷新;
/// 深水区:watch() 订阅数据库,数据一变【自动推送】新事件,
/// 页面 StreamBuilder 自动重建——像数据库版的响应式。
///
/// 二、本页怎么演示:
/// - 上方:添加错题(写入 Isar);
/// - 下方:列表用 watchAll() 订阅 → 添加/删除【无需任何刷新】
/// 列表自动更新(fireImmediately 首帧先出当前数据);
/// - 对比区:一个"手动查询"按钮模拟浅水区做法(要自己点刷新);
/// - 点列表项删除 → watch 同样自动收到删除事件。
///
/// 三、为什么经 store 而不是直接用 AppDatabase:
/// Isar 集合成员只在 native 编译,web 直接引用会编译失败;
/// WrongQuestionStore 条件导出(io 真实现 / web 空实现),
/// 页面永远只面向 store,web 端用 kIsWeb 降级提示。
///
/// 四、高频坑:
/// 1. watch 返回的是 Stream,页面必须配合 StreamBuilder,
/// 否则要自己 cancel(本例 StreamBuilder 自动管理);
/// 2. fireImmediately: false 时首帧是空的,通常要开 true;
/// 3. 写操作必须 writeTxn 包住(store 已处理);
/// 4. Web 端无法跑 Isar(空实现),只能真机/桌面看效果。
/// ============================================================
class IsarWatchDemoPage extends StatefulWidget {
const IsarWatchDemoPage({super.key});
@override
State<IsarWatchDemoPage> createState() => _IsarWatchDemoPageState();
}
class _IsarWatchDemoPageState extends State<IsarWatchDemoPage> {
final TextEditingController _titleC = TextEditingController();
/// 对比区:手动查询结果(非 watch,需要自己点按钮刷新)
int _manualCount = -1; // -1 = 还没查过
@override
void dispose() {
_titleC.dispose();
super.dispose();
}
/// 添加错题(写入 Isar → watch 自动推送 → 列表自动更新)
Future<void> _addWrong() async {
final title = _titleC.text.trim();
if (title.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入题干'), duration: Duration(seconds: 1)),
);
return;
}
// 模拟"把做错的题加入错题本":questionId 随机造一个
await WrongQuestionStore.instance.add(
questionId: DateTime.now().millisecondsSinceEpoch % 100000,
title: title,
wrongAnswer: '选错了',
);
_titleC.clear();
}
/// 手动查询(对比:非响应式,必须自己点才更新)
Future<void> _manualQuery() async {
final all = await WrongQuestionStore.instance.findAll();
if (!mounted) return;
setState(() => _manualCount = all.length);
}
/// 删除错题(watch 也会收到删除事件,列表自动减少)
Future<void> _delete(dynamic item) async {
final id = item.id as int;
await WrongQuestionStore.instance.remove(id);
}
@override
Widget build(BuildContext context) {
// Web 降级:Isar 仅 native 可用
if (kIsWeb) {
return const Scaffold(
body: Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text(
'Isar 不支持 Web 调试\n(条件导出空实现)\n请在 Android/iOS/桌面端查看效果',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, height: 1.8),
),
),
),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Isar watch 响应式 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTipCard(),
SizedBox(height: 12.h),
// 添加错题
Row(
children: [
Expanded(
child: TextField(
controller: _titleC,
decoration: const InputDecoration(
hintText: '输入题干,如:1+1=?',
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addWrong(),
),
),
SizedBox(width: 8.w),
FilledButton(
onPressed: _addWrong,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF3B82F6),
),
child: const Text('加入错题'),
),
],
),
SizedBox(height: 12.h),
// 对比区:手动查询
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _manualQuery,
child: Text(
_manualCount < 0
? '浅水区:手动查询(点我刷新)'
: '浅水区:手动查询 = $_manualCount 条',
),
),
),
],
),
SizedBox(height: 12.h),
const Text(
'深水区:watch 自动刷新(无需任何手动操作)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF3B82F6),
),
),
SizedBox(height: 8.h),
_buildWatchList(),
],
),
),
);
}
/// 说明卡片
Widget _buildTipCard() {
return Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(12.r),
),
child: const Text(
'watch() = 订阅数据库:添加/删除错题后,下方列表自动更新,'
'不用手动刷新。对比"浅水区"按钮要自己点才更新。'
'点列表项可删除。',
style: TextStyle(fontSize: 13, color: Color(0xFF3B82F6)),
),
);
}
/// watch 订阅列表:数据一变自动重建
Widget _buildWatchList() {
return StreamBuilder<List<dynamic>>(
// 响应式核心:数据库变化自动推送;fireImmediately 首帧先出一份
stream: WrongQuestionStore.instance.watchAll(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Text('监听出错');
}
final items = snapshot.data ?? const <dynamic>[];
if (items.isEmpty) {
return Container(
padding: EdgeInsets.all(24.h),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: const Text(
'(错题本为空,添加一条试试)',
style: TextStyle(color: Color(0xFF999999)),
),
);
}
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
children: [
for (int i = 0; i < items.length; i++) ...[
_buildItem(items[i], i),
if (i < items.length - 1)
const Divider(height: 1, color: Color(0xFFF0F2F5)),
],
],
),
);
},
);
}
Widget _buildItem(dynamic item, int index) {
// item 是 WrongQuestion 对象(页面不 import 模型,用 dynamic 访问)
final title = item.title as String? ?? '无题干';
final wrongAt = item.wrongAt as DateTime?;
return InkWell(
onTap: () => _delete(item),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
child: Row(
children: [
const Icon(Icons.cancel, color: Color(0xFFEF4444), size: 18),
SizedBox(width: 8.w),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, color: Color(0xFF333333)),
),
),
Text(
wrongAt != null
? '${wrongAt.month}-${wrongAt.day} ${wrongAt.hour}:${wrongAt.minute}'
: '',
style: const TextStyle(fontSize: 11, color: Color(0xFF999999)),
),
const Icon(Icons.close, size: 14, color: Color(0xFFC0C4CC)),
],
),
),
);
}
}
+315
View File
@@ -0,0 +1,315 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// ListView 列表演示页
///
/// 一、两种使用方式:
/// 1. 直接运行本页面:runApp(const MaterialApp(home: ListDemoPage()))
/// 2. 复制某段核心代码到任意 Scaffold 的 body 中。
///
/// 二、核心区别(重点记忆):
/// ListView(children) : 一次性创建并渲染【全部】item,适合少量(<30)或异类组件
/// ListView.builder : 懒加载,只渲染屏幕可见的 item,滑到才创建,适合大量数据
///
/// 三、ListView.builder 必填两个参数:
/// itemCount : 数据总条数(决定可滚动范围)
/// itemBuilder : (context, index) 根据下标生成每一行
///
/// 四、同族 API:
/// ListView.builder : 纯列表,item 之间没有分隔线
/// ListView.separated: item 之间自动插分隔线,需额外传 separatorBuilder
/// (比在 itemBuilder 里手动加 Divider 更规范)
///
/// 五、高频坑:
/// ListView 放进 Column/Row 必须给固定高度(SizedBox)或 Expanded,
/// 否则报 "Vertical viewport was given unbounded height"。
/// ============================================================
class ListDemoPage extends StatelessWidget {
const ListDemoPage({super.key});
// 演示数据(静态常量,可以写进 const 构建里)
static const List<String> _fruits = [
'苹果', '香蕉', '橙子', '葡萄', '西瓜', '草莓', '芒果', '菠萝',
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('ListView 列表 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _SectionTitle('Demo1:ListView(children) 一次性全部渲染'),
Text(
'混装不同组件(卡片 + ListTile + for 循环生成),'
'item 全部创建,适合 <30 条',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
SizedBox(
height: 300.h, // 关键:ListView 必须要有确定高度
child: ListView(
children: [
// ① 不同种类的组件直接混装
Container(
margin: EdgeInsets.only(bottom: 8.h),
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(8.r),
),
child: const Text(
'顶部公告:今天学习 ListView!',
style: TextStyle(color: Color(0xFF1565C0)),
),
),
const ListTile(
leading: Icon(Icons.home, color: Color(0xFF3B82F6)),
title: Text('首页入口'),
trailing: Icon(Icons.chevron_right),
),
Divider(height: 1.h, indent: 16, endIndent: 16),
// ② children 里直接写 for 循环生成同构条目
for (final fruit in _fruits)
ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFFEDF2FF),
child: Text(
fruit.substring(0, 1),
style: const TextStyle(color: Color(0xFF3B82F6)),
),
),
title: Text(fruit),
trailing: const Icon(Icons.chevron_right),
),
],
),
),
SizedBox(height: 32.h),
const _SectionTitle('Demo2:ListView.separated + 下拉刷新'),
Text(
'separated 自动插分隔线 + RefreshIndicator 下拉刷新,'
'下拉后新数据插到顶部',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
const _RefreshableList(),
],
),
),
);
}
}
/// 区块标题
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: 4.h),
child: Text(
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
);
}
}
/// 下拉刷新 + 上拉加载列表 —— StatefulWidget 版本
/// 关键点:
/// 1. RefreshIndicator 包住可滚动组件,onRefresh 必须返回 Future;
/// 2. physics: AlwaysScrollableScrollPhysics() 内容不满一屏也能下拉;
/// 3. ScrollController 监听滚动位置,触底前 200px 触发 _loadMore;
/// 4. ScrollController 必须在 dispose() 里释放,防内存泄漏;
/// 5. _isLoadingMore 防重入(滚动事件会高频触发)、_hasMore 判断是否还有下一页;
/// 6. 数据项用 Dart 3 记录类型 (编号, 来源),区分初始/刷新/加载更多。
class _RefreshableList extends StatefulWidget {
const _RefreshableList();
@override
State<_RefreshableList> createState() => _RefreshableListState();
}
class _RefreshableListState extends State<_RefreshableList> {
// 滚动控制器:监听触底
final ScrollController _scrollController = ScrollController();
// 数据源:Dart3 记录 (编号, 来源标签)
List<(int, String)> _items = [];
int _page = 1; // 当前页码(模拟分页)
bool _hasMore = true; // 是否还有下一页
bool _isLoadingMore = false; // 防止滚动事件重复触发加载
bool _showBackToTop = false; // 是否显示"回到顶部"按钮
@override
void initState() {
super.initState();
_items = List.generate(10, (i) => (i + 1, '初始数据'));
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose(); // 必须释放,防内存泄漏
super.dispose();
}
/// 滚动监听:触底加载 + 控制"回到顶部"按钮显隐
void _onScroll() {
if (!_scrollController.hasClients) return; // hasClients:这个控制器有没有真正挂载到滚动组件上。
final pos = _scrollController.position;
// 触底前 200px 加载下一页
if (pos.pixels >= pos.maxScrollExtent - 200) {
_loadMore();
}
// 滚过 200px 才显示"回到顶部"按钮(用 != 对比,避免每像素都 setState)
final shouldShow = pos.pixels > 200;
if (shouldShow != _showBackToTop) {
setState(() => _showBackToTop = shouldShow);
}
}
/// 平滑滚回顶部
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
/// 下拉刷新:把新数据插到列表最前面
Future<void> _onRefresh() async {
// 模拟网络请求延迟 1 秒
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return; // 页面可能已被销毁
// await 1 秒后页面可能已被用户切走,此时 context 已失效,直接操作会崩。 if (!mounted) return; 是异步回调的通用兜底。
setState(() {
// 刷新:模拟从服务端拉到新数据,插到列表最前面
final newStart = _items.first.$1 + 100;
_items = [
...List.generate(5, (i) => (newStart + i, '刷新新增')),
..._items,
];
});
// 刷新完成提示
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('刷新成功,新增了 5 条数据')),
);
}
/// 触底加载下一页:追加到列表末尾
Future<void> _loadMore() async {
if (_isLoadingMore || !_hasMore) return; // 防重入
_isLoadingMore = true;
setState(() {}); // 先刷新,显示底部 loading
await Future.delayed(const Duration(milliseconds: 800)); // 模拟网络
if (!mounted) return;
setState(() {
final start = _items.last.$1 + 1;
_items = [..._items, ...List.generate(10, (i) => (start + i, '加载更多'))];
_page++;
_hasMore = _page < 5; // 模拟:到第 5 页后没有更多数据
});
_isLoadingMore = false;
}
/// 列表末尾的 footer:加载中 / 没有更多
Widget _buildFooter() {
return Padding(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: Center(
child: _hasMore
? SizedBox(
width: 20.w,
height: 20.h,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: Text(
'— 没有更多了 —',
style: TextStyle(fontSize: 12.sp, color: Color(0xFF999999)),
),
),
);
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
SizedBox(
height: 300.h, // 固定高度,否则放 Column 里会报 unbounded height
child: RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.separated(
// 关键:内容不满一屏时也能触发下拉手势
physics: const AlwaysScrollableScrollPhysics(),
controller: _scrollController,
itemCount: _items.length + 1, // 多出的一行是 footer
separatorBuilder: (_, i) =>
Divider(height: 1.h, indent: 16, endIndent: 16),
itemBuilder: (context, index) {
// 最后一行渲染 footer
if (index == _items.length) return _buildFooter();
final (num, tag) = _items[index]; // 记录解构
// 按来源标签决定颜色(Dart3 switch 表达式)
final Color bg = switch (tag) {
'刷新新增' => const Color(0xFFE8F5E9),
'加载更多' => const Color(0xFFE3F2FD),
_ => const Color(0xFFFFF3E0),
};
final Color fg = switch (tag) {
'刷新新增' => const Color(0xFF2E7D32),
'加载更多' => const Color(0xFF1565C0),
_ => const Color(0xFFE65100),
};
return ListTile(
leading: CircleAvatar(
backgroundColor: bg,
child: Text('$num', style: TextStyle(color: fg)),
),
title: Text('数据 #$num'),
subtitle: Text(tag),
);
},
),
),
),
// 回到顶部悬浮按钮:Stack 定位在列表右下角
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 动画冲突
backgroundColor: const Color(0xFF3B82F6),
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward, color: Colors.white),
),
),
),
),
],
);
}
}
+192
View File
@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/services/picker_service.dart';
/// ============================================================
/// 【NotePickerPage】笔记图片选择页(学生上传笔记:多张图)
///
/// 一、本页解决什么问题:
/// 笔记上传前要先"选图 + 预览 + 确认",这就是一个真实的
/// 「多图选择页」:网格缩略图 + 右下角加号 + 底部提交按钮。
///
/// 二、和普通图片组件的区别:
/// 1. 选图走 PickerService(Web 自动降级 image_picker,
/// 原生走 wechat_assets_picker 微信风格相册,可多选);
/// 2. 缩略图双端统一:
/// - 原生 → AssetEntityImage(实体缩略图,秒开不卡顿)
/// - Web → Image.memory(字节)
/// 3. 提交按钮已预留 UploadApi.uploadImages 占位,
/// 等用户给上传接口文档后,替换 TODO 处即可打通。
///
/// 三、高频坑:
/// 1. 千万别在 GridView 里用 Image.file 判断平台——
/// dart:io 在 Web 编译不过,必须用 AssetEntityImage / Image.memory;
/// 2. 选择后要 setState 刷新网格,PickedImage 是不可变对象,
/// 直接 add 进列表再 setState 即可,不需要深拷贝;
/// 3. 上传前记得校验:没选图时按钮要置灰/提示,别发空请求。
/// ============================================================
class NotePickerPage extends StatefulWidget {
const NotePickerPage({super.key});
@override
State<NotePickerPage> createState() => _NotePickerPageState();
}
class _NotePickerPageState extends State<NotePickerPage> {
/// 已选中的图片(最多 9 张)
final List<PickedImage> _images = [];
/// 是否正在上传
bool _uploading = false;
/// 最大可选数
static const int _maxCount = 9;
/// 点击加号:弹「拍照/相册」底部弹层
Future<void> _onAddTap() async {
final picked = await PickerService.instance.showPickSheet(
context,
maxCount: _maxCount - _images.length, // 剩余可选张数
);
if (picked == null || picked.isEmpty) return;
setState(() => _images.addAll(picked));
}
/// 删除某张图
void _onRemoveTap(int index) {
setState(() => _images.removeAt(index));
}
/// 提交:预留上传接口位置(TODO: 用户提供接口文档后替换)
Future<void> _onSubmit() async {
if (_images.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请先选择笔记图片')),
);
return;
}
setState(() => _uploading = true);
// TODO: 上传接口待用户提供(UploadApi.uploadImages 已预留位置)
// 接口给到后:
// final urls = await UploadApi.instance.uploadImages(_images);
// 成功后把 urls 提交给"创建笔记"接口,再 Navigator.pop 返回。
await Future<void>.delayed(const Duration(milliseconds: 500)); // 模拟请求
if (!mounted) return;
setState(() => _uploading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已选 ${_images.length} 张图(上传接口待接入)'),
),
);
}
/// 渲染单张缩略图(双端统一,走 PickerService)
Widget _buildThumb(PickedImage image) {
return PickerService.buildThumb(image);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('笔记图片选择'),
centerTitle: true,
),
body: Column(
children: [
Expanded(
child: GridView.builder(
padding: EdgeInsets.all(16.w),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 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);
},
),
),
// 底部提交按钮
Padding(
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 20.h),
child: SizedBox(
width: double.infinity,
height: 46.h,
child: FilledButton(
onPressed: _uploading ? null : _onSubmit,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF3B82F6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(23.r),
),
),
child: Text(
_uploading ? '上传中...' : '上传笔记(${_images.length}/$_maxCount)',
style: TextStyle(fontSize: 15.sp, color: Colors.white),
),
),
),
),
],
),
);
}
/// 加号格子
Widget _buildAddCell() {
return GestureDetector(
onTap: _onAddTap,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFF5F7FA),
borderRadius: BorderRadius.circular(10.r),
border: Border.all(color: const Color(0xFFE3E8EF)),
),
child: const Icon(Icons.add, size: 34, color: Color(0xFFB0B8C4)),
),
);
}
/// 单张已选图片格子(右上角删除角标)
Widget _buildImageCell(int index) {
final image = _images[index];
return Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10.r),
child: _buildThumb(image),
),
// 右上角删除按钮
Positioned(
top: 4.h,
right: 4.w,
child: GestureDetector(
onTap: () => _onRemoveTap(index),
child: Container(
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),
),
),
),
],
);
}
}
+257
View File
@@ -0,0 +1,257 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/network/oss_api.dart';
import 'package:new_learn/core/network/oss_uploader.dart';
import 'package:new_learn/core/services/picker_service.dart';
/// ============================================================
/// 【OssUploadDemoPage】OSS 直传 Demo(客户端签名 + PUT 上传)
///
/// 一、演示链路(与 UniApp ossUpload.js 完全对照):
/// 选图 → 后端分配路径(GetXxxOSSUploadUrl) → 拿 STS(GetAliOSSSetting)
/// → V4 签名 → PUT 直传 OSS → 显示返回的 CDN URL + 上传进度。
///
/// 二、双端读取图片字节:
/// web :PickedImage.bytes(image_picker 已读好)
/// 原生 :AssetEntity.originBytes(photo_manager 纯 Dart API)
/// 这样本页不用 import dart:io,Web 也能编译。
///
/// 三、注意:
/// 本 demo 需要后端 OSS 接口可访问(本地/测试环境),
/// 403 = 签名或 STS 有问题(看 LogInterceptor 打印的响应体定位)。
/// ============================================================
class OssUploadDemoPage extends StatefulWidget {
const OssUploadDemoPage({super.key});
@override
State<OssUploadDemoPage> createState() => _OssUploadDemoPageState();
}
class _OssUploadDemoPageState extends State<OssUploadDemoPage> {
UploadScene _scene = UploadScene.avatar;
PickedImage? _image;
bool _uploading = false;
double _progress = 0;
String? _resultUrl;
String? _error;
/// 场景显示名(对应 UniApp mode)
static String _sceneName(UploadScene s) => switch (s) {
UploadScene.avatar => '头像',
UploadScene.note => '笔记',
UploadScene.aiGrading => 'AI判题',
};
/// 选图(单张)
Future<void> _pick() async {
final picked =
await PickerService.instance.showPickSheet(context, maxCount: 1);
if (picked == null || picked.isEmpty) return;
setState(() {
_image = picked.first;
_resultUrl = null;
_error = null;
_progress = 0;
});
}
/// 上传:读字节 → 4 步链路直传 OSS
Future<void> _upload() async {
final image = _image;
if (image == null) return;
// 读图片字节:web 用 bytes,原生用 AssetEntity.originBytes(无需 dart:io)
final Uint8List bytes = image.bytes ??
await image.entity?.originBytes ??
Uint8List(0);
if (bytes.isEmpty) {
setState(() => _error = '无法读取图片数据,请重新选择');
return;
}
final fileName =
image.name ?? 'image_${DateTime.now().millisecondsSinceEpoch}.jpg';
setState(() {
_uploading = true;
_progress = 0;
_error = null;
_resultUrl = null;
});
try {
final url = await OssUploader.uploadImage(
bytes: bytes,
fileName: fileName,
scene: _scene,
onSendProgress: (sent, total) {
if (total > 0) {
setState(() => _progress = sent / total);
}
},
);
if (!mounted) return;
setState(() {
_uploading = false;
_progress = 1;
_resultUrl = url;
});
} catch (e) {
if (!mounted) return;
setState(() {
_uploading = false;
_error = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('OSS 直传'), centerTitle: true),
body: ListView(
padding: EdgeInsets.all(16.w),
children: [
_tip(
'链路:选图 → 后端分配路径(GetXxxOSSUploadUrl) → 拿STS'
'(GetAliOSSSetting) → V4签名 → PUT直传OSS。\n'
'需要后端 OSS 接口可访问;403 看控制台响应体定位签名问题。',
),
SizedBox(height: 20.h),
// ─── 场景选择 ───
Text('上传场景',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333))),
SizedBox(height: 8.h),
Wrap(
spacing: 10.w,
children: UploadScene.values
.map((s) => ChoiceChip(
label: Text(_sceneName(s)),
selected: _scene == s,
onSelected: _uploading
? null
: (_) => setState(() => _scene = s),
))
.toList(),
),
SizedBox(height: 20.h),
// ─── 已选图片 ───
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: _image == null
? Text('未选择图片',
style: TextStyle(
fontSize: 13.sp, color: const Color(0xFF999999)))
: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8.r),
child: PickerService.buildThumb(
_image!,
width: 80.w,
height: 80.h,
),
),
SizedBox(width: 12.w),
Expanded(
child: Text(
_image!.name ?? '已选择图片',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp, color: const Color(0xFF333333)),
),
),
],
),
),
SizedBox(height: 12.h),
// ─── 操作按钮 ───
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _uploading ? null : _pick,
icon: const Icon(Icons.photo_library_outlined),
label: const Text('选择图片'),
),
),
SizedBox(width: 12.w),
Expanded(
child: FilledButton.icon(
onPressed: (_image == null || _uploading) ? null : _upload,
icon: const Icon(Icons.cloud_upload_outlined),
label: Text(_uploading ? '上传中...' : '上传到 OSS'),
),
),
],
),
SizedBox(height: 20.h),
// ─── 进度条 ───
if (_uploading) ...[
LinearProgressIndicator(value: _progress, minHeight: 6),
SizedBox(height: 6.h),
Text(
'${(_progress * 100).toStringAsFixed(0)}%',
style:
TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
SizedBox(height: 20.h),
],
// ─── 结果 ───
if (_resultUrl != null) ...[
_tip('上传成功!访问地址:', icon: Icons.check_circle, color: const Color(0xFF10B981)),
SizedBox(height: 8.h),
SelectableText(
_resultUrl!,
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF3B82F6)),
),
],
if (_error != null) ...[
_tip('上传失败:$_error',
icon: Icons.error_outline, color: const Color(0xFFEF4444)),
],
],
),
);
}
Widget _tip(String text, {IconData? icon, Color? color}) {
final accent = color ?? const Color(0xFF3B82F6);
return Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(10.r),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: accent),
SizedBox(width: 8.w),
],
Expanded(
child: Text(
text,
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF666666)),
),
),
],
),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 选择器页
/// 点击选项后 pop 带回结果(返回值的经典用法)
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),
// pop 带结果:第二个参数就是要返回的值
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, '女'),
),
],
),
);
}
}
+246
View File
@@ -0,0 +1,246 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【响应式布局 Demo】一套代码同时适配 手机 / Pad
///
/// 一、核心概念:屏幕适配 ≠ 响应式布局(两个维度!)
/// 屏幕适配(flutter_screenutil):
/// 所有尺寸按比例缩放,解决"不同屏幕比例不变形";
/// 响应式布局(本 Demo):
/// 根据屏幕宽度【改变布局结构】——手机单列、Pad 双列。
/// 两者配合使用:尺寸用 .w/.h/.sp 缩放,结构用断点切换。
///
/// 二、区分手机 / Pad 的标准(Material 官方断点):
/// 宽度 < 600dp 手机
/// 宽度 600 ~ 839dp 小平板 / 折叠屏
/// 宽度 >= 840dp 大平板 / 桌面
/// ⚠ 关键:判断的是【屏幕宽度】,不是 Platform(Android/iOS)!
/// iOS 也有 iPad,Android 也有平板,平台判断永远分不清它们。
///
/// 三、两种拿宽度/可用空间的方式:
/// MediaQuery.sizeOf(context).width → 整个屏幕宽度
/// LayoutBuilder 的 constraints → 父容器实际给你的可用宽度
/// 区别:页面铺满屏幕时两者一样;
/// 页面内有可伸缩区域(侧栏收起/展开)时,必须用 LayoutBuilder。
/// 本 Demo 用 LayoutBuilder,因为它能拿到最真实的可用宽度。
///
/// 四、本 Demo 演示:
/// 同一个数据列表:
/// 手机 → 单列 ListTile
/// Pad → 双列卡片网格(列数按宽度自适应)
/// 顶部实时显示当前模式和宽度,旋转 Pad 或换设备即可看到切换。
/// ============================================================
class ResponsiveDemoPage extends StatelessWidget {
const ResponsiveDemoPage({super.key});
/// Material 断点:宽度 >= 600dp 视为 Pad
static const double _tabletBreakpoint = 600;
/// 每列卡片的最小宽度(用于自适应列数)
static const double _minCardWidth = 280;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('响应式:手机 vs Pad')),
body: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final isTablet = width >= _tabletBreakpoint;
// 列数自适应:每列至少 280dp 宽
final columns = (width / _minCardWidth).floor().clamp(1, 4);
return Column(
children: [
// 顶部模式指示条(实时反馈,方便调试)
_ModeBar(width: width, isTablet: isTablet),
Expanded(
child: isTablet
? _buildGrid(columns) // Pad:多列网格
: _buildList(), // 手机:单列列表
),
],
);
},
),
);
}
/// Pad 布局:双列(或更多列)卡片网格
Widget _buildGrid(int columns) {
return 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]),
);
}
/// 手机布局:单列列表
Widget _buildList() {
return ListView.separated(
padding: EdgeInsets.all(12.w),
itemCount: _items.length,
separatorBuilder: (_, _) => SizedBox(height: 8.h),
itemBuilder: (context, i) => _ListTileItem(item: _items[i]),
);
}
/// 演示数据
static const List<_Item> _items = [
_Item('单词本', '每日 20 个新词,按记忆曲线复习', Color(0xFF3B82F6)),
_Item('语法练习', '时态、从句专项训练', Color(0xFF8E6FE8)),
_Item('听力磨耳朵', '每日一段真题听力', Color(0xFFF05252)),
_Item('错题本', '自动收录做错的题', Color(0xFFFF9800)),
_Item('阅读打卡', '精选短文,每日一篇', Color(0xFF4CAF50)),
_Item('口语跟读', 'AI 评分纠正发音', Color(0xFF00ACC1)),
_Item('作文批改', '真题范文与点评', Color(0xFFEC407A)),
_Item('模拟考试', '历年真题限时训练', Color(0xFF5C6BC0)),
];
}
/// 模式指示条:显示当前是手机还是 Pad 模式 + 实时宽度
class _ModeBar extends StatelessWidget {
const _ModeBar({required this.width, required this.isTablet});
final double width;
final bool isTablet;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
color: isTablet ? const Color(0xFFE8F5E9) : const Color(0xFFE3F2FD),
child: Row(
children: [
Icon(
isTablet ? Icons.tablet : Icons.phone_android,
size: 18.w,
color: isTablet ? const Color(0xFF2E7D32) : const Color(0xFF1565C0),
),
SizedBox(width: 8.w),
Text(
isTablet ? 'Pad 模式(多列网格)' : '手机模式(单列列表)',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: isTablet ? const Color(0xFF2E7D32) : const Color(0xFF1565C0),
),
),
const Spacer(),
Text(
'宽 ${width.toStringAsFixed(0)}dp',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF888888)),
),
],
),
);
}
}
/// Pad 用的卡片
class _CardItem extends StatelessWidget {
const _CardItem({required this.item});
final _Item item;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
padding: EdgeInsets.all(14.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40.w,
height: 40.h,
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10.r),
),
child: Icon(Icons.auto_stories, color: item.color, size: 22.w),
),
const Spacer(),
Text(
item.title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: const Color(0xFF333333),
),
),
SizedBox(height: 4.h),
Text(
item.desc,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
],
),
);
}
}
/// 手机用的列表项
class _ListTileItem extends StatelessWidget {
const _ListTileItem({required this.item});
final _Item item;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: ListTile(
leading: Container(
width: 40.w,
height: 40.h,
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10.r),
),
child: Icon(Icons.auto_stories, color: item.color, size: 22.w),
),
title: Text(
item.title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
subtitle: Text(
item.desc,
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
trailing: const Icon(Icons.chevron_right, color: Color(0xFFC0C6CF)),
),
);
}
}
/// 演示数据模型
class _Item {
const _Item(this.title, this.desc, this.color);
final String title;
final String desc;
final Color color;
}
+148
View File
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:new_learn/core/components/rich_text_content.dart';
/// ============================================================
/// 【RichTextDemoPage】富文本渲染 Demo
///
/// 一、本页演示什么:
/// 1. 题目/解析 HTML 完整渲染:标题、粗体、图片、上标下标、
/// <ul> 选项、<table> 表格;
/// 2. <audio> 标签自动替换成本项目 AudioPlayerCard;
/// 3. 学生端能力:字号 Aa± 实时调节、深色模式切换(textStyle 注入);
/// 4. 自定义样式:.answer 类 → 红色加粗(customStylesBuilder)。
///
/// 二、图片说明:
/// 用 asset:// 协议加载本地图片(最稳),实际后端返回的是
/// 完整 URL 或相对路径(相对路径需业务层拼 baseUrl)。
///
/// 三、高频坑:
/// 1. textStyle 只影响文本,图片/表格/AudioPlayerCard 是各自样式,
/// 深色模式下卡片白底不随主题变(业务侧需按需适配);
/// 2. 内联 CSS 只支持常用属性,复杂布局会降级;
/// 3. 以后接公式(LaTeX)在 RichTextContent 的 customWidgetBuilder
/// 里扩展,本 demo 未含公式。
/// ============================================================
class RichTextDemoPage extends StatefulWidget {
const RichTextDemoPage({super.key});
@override
State<RichTextDemoPage> createState() => _RichTextDemoPageState();
}
class _RichTextDemoPageState extends State<RichTextDemoPage> {
/// 字号(12 ~ 24,学生端可调)
double _fontSize = 15;
/// 深色模式演示开关
bool _dark = false;
/// 模拟后端返回的题目 HTML(含图片/上标/下标/表格/音频/解析)
static const String _html = '''
<h3>第 1 题 · 阅读理解</h3>
<p>阅读下面的材料,完成题目。</p>
<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"/>
<p>根据材料,下列说法正确的是:</p>
<ul>
<li>A. 光合作用只发生在夜间</li>
<li>B. 光合作用会释放氧气</li>
<li>C. 光合作用会消耗氧气</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>
<video src="https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4" title="实验视频" controls></video>
<p><b>【解析】</b>正确答案:<span class="answer">B</span>。光合作用光反应阶段必须在有光的条件下进行,并释放氧气。</p>
''';
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _dark ? const Color(0xFF1E1E1E) : Colors.white,
appBar: AppBar(
title: const Text('富文本渲染'),
centerTitle: true,
backgroundColor: _dark ? const Color(0xFF262626) : null,
foregroundColor: _dark ? Colors.white : null,
),
body: Column(
children: [
_buildControlBar(),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: RichTextContent(
html: _html,
// 字号 + 深色模式都通过 textStyle 注入(学生端统一入口)
textStyle: TextStyle(
fontSize: _fontSize,
height: 1.6,
color: _dark
? Colors.white70
: const Color(0xFF333333),
),
// 自定义样式:.answer 类 → 红色加粗
customStylesBuilder: (element) {
if (element.className.contains('answer')) {
return const {'color': '#EF4444', 'font-weight': 'bold'};
}
return null;
},
),
),
),
],
),
);
}
/// 顶部控制栏:字号 Aa- / Aa+ + 深色开关
Widget _buildControlBar() {
return Container(
color: _dark ? const Color(0xFF262626) : const Color(0xFFF7F8FA),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.text_decrease, size: 20),
color: _dark ? Colors.white : null,
onPressed: () => setState(() {
if (_fontSize > 12) _fontSize -= 1;
}),
),
Expanded(
child: Slider(
value: _fontSize,
min: 12,
max: 24,
divisions: 12,
activeColor: const Color(0xFF3B82F6),
onChanged: (v) => setState(() => _fontSize = v),
),
),
IconButton(
icon: const Icon(Icons.text_increase, size: 20),
color: _dark ? Colors.white : null,
onPressed: () => setState(() {
if (_fontSize < 24) _fontSize += 1;
}),
),
const SizedBox(width: 4),
Text(
'深色',
style: TextStyle(fontSize: 13, color: _dark ? Colors.white70 : null),
),
Switch(
value: _dark,
activeThumbColor: const Color(0xFF3B82F6),
onChanged: (v) => setState(() => _dark = v),
),
const SizedBox(width: 4),
],
),
);
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:new_learn/pages/demo/detail_page.dart';
import 'package:new_learn/pages/demo/router_home_page.dart';
/// ============================================================
/// 路由导航:应用入口(只负责组装,不写页面)
///
/// 一、直接运行:runApp(const RouterApp())
///
/// 二、组件化结构(对应 Vue 的 pages/ 目录):
/// lib/pages/router_home_page.dart → 首页(三个跳转按钮)
/// lib/pages/detail_page.dart → 详情页
/// lib/pages/picker_page.dart → 选择器页
///
/// 三、命名规范:
/// 文件名 snake_case(小写下划线);类名 PascalCase;
/// 跨文件用 import 引入;每个公开类一个文件。
/// ============================================================
class RouterApp extends StatelessWidget {
const RouterApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '路由导航 Demo',
theme: ThemeData(colorSchemeSeed: const Color(0xFF3B82F6)),
// 命名路由表:name -> 页面构造(详情页在这里注册)
routes: {
'/detail': (context) => const DetailPage(),
},
home: const RouterHomePage(),
);
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/pages/demo/detail_page.dart';
import 'package:new_learn/pages/demo/picker_page.dart';
/// 路由首页
/// 三个按钮演示三种跳转姿势(push 传参 / 返回结果 / 命名路由)
class RouterHomePage extends StatelessWidget {
const RouterHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('路由导航 Demo')),
body: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ---- 方式1:push + 构造函数传参 ----
FilledButton.icon(
icon: const Icon(Icons.send),
label: const Text('方式1:push 构造传参 → 详情页'),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const DetailPage(name: '张三', age: 18),
),
);
},
),
SizedBox(height: 12.h),
// ---- 方式2:push + 等待返回结果 ----
FilledButton.tonalIcon(
icon: const Icon(Icons.checklist),
label: const Text('方式2:push 选择器并接收返回结果'),
onPressed: () async {
// 注意:push 返回的是 Future,用 await 接收 pop 带回来的值
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const PickerPage()),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('你选择了:${result ?? '未选择'}')),
);
}
},
),
SizedBox(height: 12.h),
// ---- 方式3:pushNamed 命名路由 + arguments 传参 ----
FilledButton.icon(
icon: const Icon(Icons.route),
label: const Text('方式3:pushNamed 命名路由 + arguments'),
onPressed: () {
Navigator.pushNamed(
context,
'/detail',
arguments: {'name': '李四', 'age': 25},
);
},
),
SizedBox(height: 24.h),
Text(
'知识点:页面跳转是【入栈/出栈】。'
'返回页 pop 时带值 → 接收页 await push 拿到值。'
'命名路由的 arguments 是 dynamic,取值需判空强转。',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
],
),
),
);
}
}
+257
View File
@@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/config/app_config.dart';
/// ============================================================
/// 【SeedPaletteDemo】Seed 色板 —— 验证"主题 seed 是否生效"的演示页
///
/// 一、原理:
/// MaterialApp 的 theme.colorSchemeSeed(来自 AppConfig.theme.seedColor)
/// 会自动生成整套 ColorScheme(primary/secondary/tertiary/...)。
/// 页面里【不指定颜色】的 Material 组件,颜色全部取自这套色板:
/// FilledButton/Checkbox/Switch/Radio/进度圈/聚焦输入框 等。
/// 所以换一个品牌(seed 不同),整页组件整体变色 —— 这就是
/// "seed 驱动全局 Material 色板" 的直接证明。
///
/// 二、怎么验证:
/// flutter run → 本页所有组件是 SkyBlue 蓝
/// flutter run --dart-define=BRAND=chucai → 本页所有组件变 Red 红
///
/// 三、对比理解(对照登录页为什么不变色):
/// 本页组件【没写死颜色】→ 跟随 seed 变;
/// 登录页所有颜色写死 Color(0xFF3B82F6) 等 → 永远蓝色,不随主题走。
/// ============================================================
class SeedPaletteDemoPage extends StatefulWidget {
const SeedPaletteDemoPage({super.key});
@override
State<SeedPaletteDemoPage> createState() => _SeedPaletteDemoPageState();
}
class _SeedPaletteDemoPageState extends State<SeedPaletteDemoPage> {
final _focusController = TextEditingController();
bool _checked = true;
bool _switched = true;
int _chipSelected = 1;
@override
void dispose() {
// 输入框 controller 必须在 dispose 释放(防内存泄漏)
_focusController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final brandTheme = AppConfig.theme;
return Scaffold(
appBar: AppBar(
title: const Text('Seed 色板'),
centerTitle: true,
),
body: ListView(
padding: EdgeInsets.all(16.w),
children: [
// ─── ① 当前品牌 / seed 信息卡 ───
_sectionTitle('① 当前生效的 seed'),
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Row(
children: [
// seed 色块:就是 AppConfig.theme.seedColor
Container(
width: 56.w,
height: 56.h,
decoration: BoxDecoration(
color: brandTheme.seedColor,
borderRadius: BorderRadius.circular(12.r),
),
),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppConfig.appName,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
SizedBox(height: 4.h),
Text(
'主题 ${brandTheme.name} · seed '
'${_hex(brandTheme.seedColor)}',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF999999),
),
),
],
),
),
],
),
),
SizedBox(height: 24.h),
// ─── ② 组件区说明 ───
_sectionTitle('② 下列组件【未写死颜色】'),
_tipCard('它们的颜色全部取自 colorSchemeSeed 自动生成的色板。'
'跑不同品牌包,下面所有组件整体变色。'),
// ─── ③ 按钮组 ───
_sectionTitle('按钮(默认取 primary)'),
Row(
children: [
FilledButton(
onPressed: () {},
child: const Text('FilledButton'),
),
SizedBox(width: 12.w),
OutlinedButton(
onPressed: () {},
child: const Text('OutlinedButton'),
),
SizedBox(width: 12.w),
TextButton(
onPressed: () {},
child: const Text('TextButton'),
),
],
),
SizedBox(height: 24.h),
// ─── ④ 勾选 / 开关 / 选择标签 ───
_sectionTitle('勾选 / 开关 / 选择标签(选中色取自 seed 色板)'),
Row(
children: [
Checkbox(
value: _checked,
onChanged: (v) => setState(() => _checked = v!),
),
const Text('Checkbox'),
SizedBox(width: 24.w),
Switch(
value: _switched,
onChanged: (v) => setState(() => _switched = v),
),
SizedBox(width: 12.w),
const Text('Switch'),
SizedBox(width: 24.w),
ChoiceChip(
label: const Text('选项1'),
selected: _chipSelected == 1,
onSelected: (_) => setState(() => _chipSelected = 1),
),
SizedBox(width: 12.w),
ChoiceChip(
label: const Text('选项2'),
selected: _chipSelected == 2,
onSelected: (_) => setState(() => _chipSelected = 2),
),
],
),
SizedBox(height: 24.h),
// ─── ⑤ 进度指示器 ───
_sectionTitle('进度指示器(默认取 primary)'),
Row(
children: [
// 注意:这里不能写 const —— .w/.h 是 ScreenUtil 扩展方法,
// extension 方法不允许出现在 const 表达式里(const_eval_extension_method)
SizedBox(
width: 32.w,
height: 32.h,
child: const CircularProgressIndicator(strokeWidth: 3),
),
SizedBox(width: 20.w),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4.r),
child: const LinearProgressIndicator(value: 0.6),
),
),
],
),
SizedBox(height: 24.h),
// ─── ⑥ 聚焦输入框 ───
_sectionTitle('聚焦输入框(边框取 primary)'),
_tipCard('点进输入框,光标 + 聚焦边框颜色 = 当前 seed 主色。'),
TextField(
controller: _focusController,
decoration: InputDecoration(
labelText: '点击聚焦看边框颜色',
border: const OutlineInputBorder(),
),
),
SizedBox(height: 24.h),
// ─── ⑦ 验证提示 ───
_sectionTitle('⑦ 怎么看到变化'),
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'flutter run --dart-define=BRAND=chucai\n'
'→ 本页所有组件变成红色系。\n\n'
'flutter run(默认爱学蝶变)\n'
'→ 恢复 SkyBlue 蓝色系。',
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF3B82F6)),
),
),
],
),
);
}
/// 段落小标题
Widget _sectionTitle(String text) {
return Padding(
padding: EdgeInsets.only(bottom: 10.h),
child: Text(
text,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
);
}
/// 灰色提示卡
Widget _tipCard(String text) {
return Container(
padding: EdgeInsets.all(12.w),
margin: EdgeInsets.only(bottom: 12.h),
decoration: BoxDecoration(
color: const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(10.r),
),
child: Text(
text,
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF666666)),
),
);
}
/// Color → #RRGGBB 显示(Flutter 3.27+ 用 toARGB32 取色值)
String _hex(Color c) {
final v = c.toARGB32() & 0xFFFFFF;
return '#${v.toRadixString(16).padLeft(6, '0').toUpperCase()}';
}
}
+525
View File
@@ -0,0 +1,525 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/services/share_service.dart';
import 'package:new_learn/core/services/picker_service.dart';
/// ============================================================
/// 【ShareDemoPage】分享功能演示页
///
/// 一、演示内容:
/// 1. 分享纯文本 / 链接
/// 2. 选图后分享(单张图片)
/// 3. Widget 截图分享(模拟“攻克海报”场景)
/// 4. Widget 截图预览(不分享,只截图看效果)
/// 5. 保存到相册(选图 / 截图 → 写入系统相册)
///
/// 二、核心能力对应:
/// ShareService.shareText → 场景 1
/// ShareService.shareImage → 场景 2(先 PickerService 选图)
/// ShareService.shareFromWidget → 场景 3
/// ShareService.captureWidget → 场景 4
/// ShareService.saveToGallery → 场景 5a
/// ShareService.saveToGalleryFromBytes → 场景 5b
///
/// 三、使用方式:
/// 从任务页 Demo 列表进入,或直接:
/// context.push('/share-demo'); (需先在 app_router.dart 注册)
/// ============================================================
class ShareDemoPage extends StatefulWidget {
const ShareDemoPage({super.key});
@override
State<ShareDemoPage> createState() => _ShareDemoPageState();
}
class _ShareDemoPageState extends State<ShareDemoPage> {
final TextEditingController _textController = TextEditingController(
text: '来看看这个超棒的学习 App!https://example.com',
);
bool _sharing = false;
String? _lastResult;
@override
void dispose() {
_textController.dispose();
super.dispose();
}
/// 统一结果展示
void _showResult(String msg) {
setState(() => _lastResult = msg);
}
// ==================== 场景 1:分享文本 ====================
Future<void> _shareText() async {
final text = _textController.text.trim();
if (text.isEmpty) {
_showResult('请输入要分享的内容');
return;
}
setState(() => _sharing = true);
try {
final result = await ShareService.instance.shareText(text: text);
_showResult('文本分享结果:${result.status.name}');
} catch (e) {
_showResult('分享失败:$e');
} finally {
if (mounted) setState(() => _sharing = false);
}
}
// ==================== 场景 2:选图 → 分享 ====================
Future<void> _pickAndShare() async {
setState(() => _sharing = true);
try {
final picked = await PickerService.instance.showPickSheet(
context,
maxCount: 1,
);
if (picked == null || picked.isEmpty) {
_showResult('未选择图片');
return;
}
final filePath = picked.first.path;
if (filePath == null) {
_showResult('Web 端暂不支持文件路径分享');
return;
}
final result = await ShareService.instance.shareImage(
filePath: filePath,
text: '看看这张图片!',
);
_showResult('图片分享结果:${result.status.name}');
} catch (e) {
_showResult('分享失败:$e');
} finally {
if (mounted) setState(() => _sharing = false);
}
}
// ==================== 场景 3:Widget 截图 → 分享(海报) ====================
Future<void> _sharePoster() async {
setState(() => _sharing = true);
try {
final result = await ShareService.instance.shareFromWidget(
context: context,
text: '我攻克了一道难题,快来挑战!',
builder: (_) => _buildPosterWidget(
userName: '张三同学',
taskName: '二次函数综合练习',
score: 98,
),
);
_showResult('海报分享结果:${result.status.name}');
} catch (e) {
_showResult('分享失败:$e');
} finally {
if (mounted) setState(() => _sharing = false);
}
}
// ==================== 场景 4:Widget 截图预览(不分享) ====================
Future<void> _previewPoster() async {
setState(() => _sharing = true);
try {
final bytes = await ShareService.captureWidget(
context: context,
builder: (_) => _buildPosterWidget(
userName: '张三同学',
taskName: '二次函数综合练习',
score: 98,
),
);
if (bytes == null) {
_showResult('截图失败');
return;
}
// 弹窗预览截图结果
if (!mounted) return;
showDialog(
context: context,
builder: (ctx) => Dialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.all(12),
child: Text('截图预览(海报效果)',
style: TextStyle(fontWeight: FontWeight.bold)),
),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(bytes, width: 280),
),
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'图片大小:${(bytes.length / 1024).toStringAsFixed(1)} KB',
style: const TextStyle(color: Colors.grey),
),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('关闭'),
),
],
),
),
);
_showResult('截图成功,可预览效果');
} catch (e) {
_showResult('截图失败:$e');
} finally {
if (mounted) setState(() => _sharing = false);
}
}
// ==================== 场景 5:保存到相册 ====================
/// 5a:选图 → 保存到相册
Future<void> _pickAndSaveToGallery() async {
setState(() => _sharing = true);
try {
final picked = await PickerService.instance.showPickSheet(
context,
maxCount: 1,
);
if (picked == null || picked.isEmpty) {
_showResult('未选择图片');
return;
}
final filePath = picked.first.path;
if (filePath == null) {
_showResult('Web 端暂不支持保存');
return;
}
final ok = await ShareService.saveToGallery(filePath: filePath);
_showResult(ok ? '✅ 已保存到相册' : '❌ 保存失败');
} catch (e) {
_showResult('保存失败:$e');
} finally {
if (mounted) setState(() => _sharing = false);
}
}
/// 5b:Widget 截图 → 保存到相册
Future<void> _captureAndSaveToGallery() async {
setState(() => _sharing = true);
try {
final bytes = await ShareService.captureWidget(
context: context,
builder: (_) => _buildPosterWidget(
userName: '张三同学',
taskName: '二次函数综合练习',
score: 98,
),
);
if (bytes == null) {
_showResult('截图失败');
return;
}
final ok = await ShareService.saveToGalleryFromBytes(bytes: bytes);
_showResult(ok ? '✅ 海报已保存到相册' : '❌ 保存失败');
} catch (e) {
_showResult('保存失败:$e');
} finally {
if (mounted) setState(() => _sharing = false);
}
}
// ==================== 海报 Widget(截图用) ====================
/// 模拟"攻克海报"布局(实际项目中从业务数据构建)
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: [
// Logo
const Icon(Icons.school, color: Colors.white, size: 40),
const SizedBox(height: 8),
const Text(
'爱学蝶变',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
// 用户信息
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
userName,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'攻克了 $taskName',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 13,
),
),
],
),
),
const SizedBox(height: 16),
// 分数
Text(
'$score',
style: const TextStyle(
color: Colors.white,
fontSize: 48,
fontWeight: FontWeight.w800,
height: 1,
),
),
const Text(
'分',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
const SizedBox(height: 16),
// 底部提示
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'扫码一起来挑战',
style: TextStyle(color: Colors.white, fontSize: 12),
),
),
],
),
);
}
// ==================== UI ====================
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('分享功能演示')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ---------- 场景 1:文本分享 ----------
_sectionTitle('① 分享文本 / 链接', Icons.text_fields),
SizedBox(height: 8.h),
TextField(
controller: _textController,
decoration: InputDecoration(
hintText: '输入要分享的文本或链接',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
),
maxLines: 2,
),
SizedBox(height: 8.h),
_shareButton(
label: '分享文本',
icon: Icons.share,
onPressed: _shareText,
),
SizedBox(height: 24.h),
// ---------- 场景 2:选图分享 ----------
_sectionTitle('② 选图 → 分享', Icons.photo),
SizedBox(height: 8.h),
Text(
'先从相册/拍照选一张图片,再通过系统分享面板分享出去',
style: TextStyle(fontSize: 12.sp, color: Colors.grey),
),
SizedBox(height: 8.h),
_shareButton(
label: '选图并分享',
icon: Icons.image_outlined,
onPressed: _pickAndShare,
),
SizedBox(height: 24.h),
// ---------- 场景 3:Widget 截图分享 ----------
_sectionTitle('③ Widget 截图 → 分享(海报)', Icons.card_giftcard),
SizedBox(height: 8.h),
Text(
'把 Flutter Widget 截图成图片,再通过系统分享面板分享。\n'
'这就是"攻克海报分享"的核心能力。',
style: TextStyle(fontSize: 12.sp, color: Colors.grey),
),
SizedBox(height: 8.h),
// 海报预览(静态展示)
Center(child: _buildPosterWidget(
userName: '张三同学',
taskName: '二次函数综合练习',
score: 98,
)),
SizedBox(height: 12.h),
Row(
children: [
Expanded(
child: _shareButton(
label: '截图并分享',
icon: Icons.share,
onPressed: _sharePoster,
),
),
SizedBox(width: 8.w),
Expanded(
child: _shareButton(
label: '仅截图预览',
icon: Icons.preview,
onPressed: _previewPoster,
color: const Color(0xFF64748B),
),
),
],
),
SizedBox(height: 24.h),
// ---------- 场景 5:保存到相册 ----------
_sectionTitle('④ 保存到相册', Icons.save_alt),
SizedBox(height: 8.h),
Text(
'将图片或 Widget 截图写入系统相册(用 gal 插件实现)',
style: TextStyle(fontSize: 12.sp, color: Colors.grey),
),
SizedBox(height: 8.h),
Row(
children: [
Expanded(
child: _shareButton(
label: '选图→保存',
icon: Icons.photo_library,
onPressed: _pickAndSaveToGallery,
color: const Color(0xFF059669),
),
),
SizedBox(width: 8.w),
Expanded(
child: _shareButton(
label: '截图→保存',
icon: Icons.camera_alt,
onPressed: _captureAndSaveToGallery,
color: const Color(0xFF059669),
),
),
],
),
SizedBox(height: 24.h),
// ---------- 结果展示 ----------
if (_lastResult != null)
Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: const Color(0xFFF0F9FF),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: const Color(0xFFBAE6FD)),
),
child: Row(
children: [
const Icon(Icons.info_outline,
size: 18, color: Color(0xFF0284C7)),
SizedBox(width: 8.w),
Expanded(
child: Text(
_lastResult!,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF0C4A6E),
),
),
),
],
),
),
],
),
),
);
}
/// 分区标题
Widget _sectionTitle(String title, IconData icon) {
return Row(
children: [
Icon(icon, size: 20, color: const Color(0xFF3B82F6)),
SizedBox(width: 8.w),
Text(
title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1E293B),
),
),
],
);
}
/// 分享按钮
Widget _shareButton({
required String label,
required IconData icon,
required VoidCallback onPressed,
Color? color,
}) {
return SizedBox(
height: 44.h,
child: FilledButton.icon(
onPressed: _sharing ? null : onPressed,
icon: Icon(icon, size: 18),
label: Text(label, style: TextStyle(fontSize: 13.sp)),
style: FilledButton.styleFrom(
backgroundColor: color ?? const Color(0xFF3B82F6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.r),
),
),
),
);
}
}
+157
View File
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// CustomScrollView + Sliver 系列演示页
///
/// 一、两种使用方式:
/// 1. 直接运行本页面:runApp(const MaterialApp(home: SliverDemoPage()))
/// 2. 复制 slivers 数组到任意 CustomScrollView 中。
///
/// 二、核心概念:
/// Sliver = "碎片"。CustomScrollView 把多个 sliver 拼成一个
/// 统一的滚动视图(共用一个滚动控制器,整页一起滚)。
///
/// 三、常用 sliver:
/// SliverAppBar : 可折叠/固定的 AppBar(pinned 固定、floating 浮出)
/// SliverToBoxAdapter : 把普通组件(文字/卡片/图片)塞进滚动流
/// SliverGrid.builder : 网格区块(懒加载)
/// SliverList.builder : 列表区块(懒加载)
/// SliverPadding : 给某个 sliver 加内边距
///
/// 四、为什么需要 Sliver(重点):
/// ListView 不能嵌套 ListView/GridView(同向滚动会冲突);
/// Sliver 方案让"AppBar + 网格 + 列表 + 卡片"混排且整页只滚一次。
///
/// 五、和 GridView 的本质区别(重点记忆):
/// GridView 只是 CustomScrollView 的简化套餐:
/// GridView = CustomScrollView + 1 个 SliverGrid。
/// 两者都是"单个滚动 + 懒加载",区别只在混排能力:
/// · 页面只有一种内容(网格/列表)→ GridView / ListView,写起来更简单
/// · 页面要混排多种内容 → 本页这种 CustomScrollView,slivers 拆块拼装
/// 判断口诀:这页是"一个网格"还是"折叠头+网格+长列表"?后者才用 Sliver。
/// ============================================================
class SliverDemoPage extends StatelessWidget {
const SliverDemoPage({super.key});
static const List<Color> _colors = [
Color(0xFF3B82F6), Color(0xFF8E6FE8), Color(0xFFF05252),
Color(0xFFFF9800), Color(0xFF4CAF50), Color(0xFF00BCD4),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
// ① 可折叠 AppBar:下拉展开大图,上滑收起只留标题栏
SliverAppBar(
expandedHeight: 180.h, // 展开时高度
pinned: true, // 滚动时固定在顶部
backgroundColor: Color(0xFF3B82F6),
flexibleSpace: FlexibleSpaceBar(
title: Text('Sliver 系列 Demo'),
centerTitle: true,
background: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF3B82F6), Color(0xFF8E6FE8)],
),
),
),
),
),
// ② 普通组件插进滚动流:SliverToBoxAdapter
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 0),
child: Text(
'网格区块(SliverGrid,懒加载)',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
),
),
// ③ 网格区块
SliverPadding(
padding: EdgeInsets.all(16.w),
sliver: SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8.w,
crossAxisSpacing: 8.w,
),
itemCount: 12,
itemBuilder: (context, index) => _GridItem(index: index),
),
),
// ④ 区块标题
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 8.h),
child: Text(
'列表区块(SliverList,懒加载)',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
),
),
// ⑤ 列表区块:50 条,和上面的网格共用同一个滚动
SliverList.builder(
itemCount: 50,
itemBuilder: (context, index) => ListTile(
leading: CircleAvatar(
backgroundColor: _colors[index % _colors.length],
child: Text(
'$index',
style: const TextStyle(color: Colors.white),
),
),
title: Text('第 $index 条'),
subtitle: Text('Sliver 懒加载,滚动到这里才创建'),
trailing: const Icon(Icons.chevron_right),
),
),
],
),
);
}
}
/// 网格项:彩色方块 + 序号
class _GridItem extends StatelessWidget {
const _GridItem({required this.index});
final int index;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: SliverDemoPage._colors[index % SliverDemoPage._colors.length],
borderRadius: BorderRadius.circular(8.r),
),
alignment: Alignment.center,
child: Text(
'$index',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
);
}
}
+345
View File
@@ -0,0 +1,345 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/persistent_snap_sheet.dart';
/// ============================================================
/// 【SmoothSheetDemoPage】常驻多停靠底部面板演示页
///
/// 一、演示场景:
/// 模拟教育 App 的"附近学校"地图页面 —— 背景是地图区域,
/// 底部常驻一个可拖拽面板,3 个停靠点:
/// · 底部收起(12%):只显示拖拽手柄 + "附近学校" 标题
/// · 中间半屏(50%):显示前 2 所学校 + "查看全部" 按钮
/// · 上拉大半屏(85%):完整学校列表,内部 ListView 可滚动
///
/// 二、交互要点:
/// · 面板可自由拖拽,松手自动吸附到最近的停靠点
/// · 背景区域(地图)始终可交互(非模态)
/// · 面板内 ListView 滚到顶部后,继续下拉可收起面板
/// · 顶部 3 个按钮可代码控制面板跳到指定停靠点
///
/// 三、核心组件:
/// PersistentSnapSheet(lib/core/components/persistent_snap_sheet.dart)
/// —— 对 smooth_sheets 的封装,详见组件注释
/// ============================================================
class SmoothSheetDemoPage extends StatelessWidget {
const SmoothSheetDemoPage({super.key});
/// 3 个停靠点:底部收起 / 半屏 / 大半屏
static const List<double> _snapFactors = [0.12, 0.5, 0.85];
/// 模拟学校数据
static const List<_SchoolItem> _schools = [
_SchoolItem('武汉市第一中学', '公立 · 省重点', '距你 1.2 km', Icons.school),
_SchoolItem('华中师范大学附中', '公立 · 全国百强', '距你 2.5 km', Icons.menu_book),
_SchoolItem('武汉市外国语学校', '公立 · 外语特色', '距你 3.1 km', Icons.translate),
_SchoolItem('育才高级中学', '公立 · 市重点', '距你 4.0 km', Icons.star),
_SchoolItem('湖北省实验中学', '公立 · 省示范', '距你 4.8 km', Icons.emoji_events),
_SchoolItem('武汉二中', '公立 · 百年名校', '距你 5.3 km', Icons.history_edu),
_SchoolItem('武钢三中', '公立 · 理科强校', '距你 6.1 km', Icons.science),
_SchoolItem('武汉市第十一中学', '公立 · 区重点', '距你 7.0 km', Icons.local_library),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// ==================== 背景层:模拟地图 ====================
_buildMapBackground(context),
// ==================== 面板层:常驻多停靠面板 ====================
PersistentSnapSheet(
snapFactors: _snapFactors,
child: Column(
children: [
// 拖拽手柄区域
_buildHandle(context),
// 面板内容(弹性填充)
Expanded(
child: _buildSheetContent(context),
),
],
),
),
],
),
);
}
// ==================== 背景层 ====================
/// 模拟地图背景(真实项目替换为地图 SDK 组件)
Widget _buildMapBackground(BuildContext context) {
return Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFE8F4FD), Color(0xFFD0E8F7)],
),
),
child: Column(
children: [
// 安全区占位
SizedBox(height: MediaQuery.of(context).padding.top + 16.h),
// 模拟搜索栏
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Container(
height: 44.h,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(22.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
SizedBox(width: 16.w),
Icon(Icons.search, color: Colors.grey[400], size: 20),
SizedBox(width: 8.w),
Text(
'搜索学校、地址...',
style: TextStyle(
color: Colors.grey[400],
fontSize: 14.sp,
),
),
],
),
),
),
const Spacer(),
// 地图提示文字
Padding(
padding: EdgeInsets.only(bottom: 200.h),
child: Column(
children: [
Icon(
Icons.map_outlined,
size: 48,
color: Colors.blue.withValues(alpha: 0.3),
),
SizedBox(height: 8.h),
Text(
'地图区域(背景可交互)',
style: TextStyle(
color: Colors.blue.withValues(alpha: 0.5),
fontSize: 14.sp,
),
),
],
),
),
const Spacer(),
],
),
);
}
// ==================== 面板内容 ====================
/// 拖拽手柄 + 停靠点快捷按钮
Widget _buildHandle(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
// 点击手柄区域切换停靠点(演示用,实际可去掉)
onTap: () {
// 点击手柄不做任何事,纯拖拽交互
},
child: Container(
padding: EdgeInsets.only(top: 12.h, bottom: 8.h),
child: Column(
children: [
// 经典拖拽指示条
Container(
width: 36.w,
height: 4.h,
decoration: BoxDecoration(
color: const Color(0xFFD1D5DB),
borderRadius: BorderRadius.circular(2.r),
),
),
SizedBox(height: 8.h),
// 标题行
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Row(
children: [
Text(
'附近学校',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1E293B),
),
),
SizedBox(width: 8.w),
Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w,
vertical: 2.h,
),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(10.r),
),
child: Text(
'${_schools.length} 所',
style: TextStyle(
fontSize: 11.sp,
color: const Color(0xFF3B82F6),
fontWeight: FontWeight.w500,
),
),
),
const Spacer(),
// 停靠点快捷按钮(演示 SheetController 用法时可加)
Icon(
Icons.keyboard_arrow_up,
color: Colors.grey[400],
size: 20,
),
],
),
),
],
),
),
);
}
/// 面板内部内容(标题 + 可滚动列表)
Widget _buildSheetContent(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 分割线
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Divider(
height: 1.h,
color: const Color(0xFFF1F5F9),
),
),
SizedBox(height: 8.h),
// 可滚动学校列表(与面板手势联动)
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,
),
),
),
],
);
}
}
// ==================== 数据模型 & 子组件 ====================
/// 学校数据模型
class _SchoolItem {
const _SchoolItem(this.name, this.tag, this.distance, this.icon);
final String name;
final String tag;
final String distance;
final IconData icon;
}
/// 学校列表项
class _SchoolListTile extends StatelessWidget {
const _SchoolListTile({required this.school, required this.index});
final _SchoolItem school;
final int index;
static const List<Color> _colors = [
Color(0xFF3B82F6),
Color(0xFF8B5CF6),
Color(0xFF10B981),
Color(0xFFF59E0B),
Color(0xFFEF4444),
Color(0xFF06B6D4),
Color(0xFFEC4899),
Color(0xFF6366F1),
];
@override
Widget build(BuildContext context) {
final color = _colors[index % _colors.length];
return Padding(
padding: EdgeInsets.symmetric(vertical: 10.h),
child: Row(
children: [
// 图标
Container(
width: 40.w,
height: 40.h,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10.r),
),
child: Icon(school.icon, color: color, size: 20),
),
SizedBox(width: 12.w),
// 信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
school.name,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1E293B),
),
),
SizedBox(height: 2.h),
Text(
'${school.tag} · ${school.distance}',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF94A3B8),
),
),
],
),
),
// 导航按钮
Container(
width: 32.w,
height: 32.h,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(8.r),
),
child: Icon(
Icons.navigation,
color: const Color(0xFF3B82F6),
size: 16,
),
),
],
),
);
}
}
+189
View File
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// Stack + Positioned 层叠布局演示页
///
/// 一、两种使用方式:
/// 1. 直接运行本页面:runApp(const MaterialApp(home: StackDemoPage()))
/// 2. 复制某个 Demo 的 Stack 代码到任意 Scaffold 的 body 中。
///
/// 二、作用:
/// Stack 实现 UI 重叠,类似 CSS 的 position:absolute。
/// Stack = 图层容器(后写的子组件盖在上面)
/// Positioned = 给子组件做绝对定位(只能直接放在 Stack 的 children 里!)
///
/// 三、核心属性:
/// Stack.alignment : 对【没有被 Positioned 包裹】的子组件做默认对齐
/// Stack.clipBehavior: Clip.none 允许子组件绘制超出 Stack 边界
/// (默认 Clip.hardEdge,超出会被裁剪看不见)
/// Positioned.top/bottom/left/right : 距离 Stack 四边的距离
/// Positioned.fill : 填满整个 Stack 容器(相当于做蒙层)
///
/// 四、三大高频坑:
/// 1. 全部子组件都是 Positioned 时 → Stack 尺寸异常!
/// 真实行为:size = 父约束最大值(constraints.biggest),不按内容算。
/// 放在无界约束(如 Column 裸放)→ debug 断言崩溃 "A Stack requires
/// bounded constraints",整个 body 都画不出来;
/// 放在有界约束 → Stack 占满整个可用区域。
/// 修复A:给 Stack 套 SizedBox 固定宽高;
/// 修复B:放一个非 Positioned 的底层组件撑起大小。
/// 2. Positioned 只能直接作为 Stack 的 children,套其他 Widget 中间层会失效。
/// 3. 做悬浮角标务必 clipBehavior: Clip.none,否则负偏移部分被裁剪掉。
/// ============================================================
class StackDemoPage extends StatelessWidget {
const StackDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Stack 层叠布局 Demo')),
body: SingleChildScrollView(
// AlwaysScrollableScrollPhysics() 永远可以滚动,内容很短也能拽动回弹
// BouncingScrollPhysics() 越界回弹(iOS 风格,拉出来空白再弹回)
// NeverScrollableScrollPhysics() ❌完全禁止滚动,就算内容溢出也不能滑动
physics: BouncingScrollPhysics(),
padding: EdgeInsets.all(26.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _SectionTitle('Demo1:基础重叠 + 右上角角标'),
// 注意:Flutter 3.44 的 Container 已不是 const 构造,外层不能再写 const Center
Center(
child: Stack(
// 未被 Positioned 的子组件默认居中
alignment: Alignment.topLeft,
// 允许内容超出 Stack,角标才能"溢出"到盒子外
clipBehavior: Clip.none, // 允许超出 Stack 边界 (默认 Clip.hardEdge,超出会被裁剪看不见)
children: [
// 底层盒子
Container(
width: 250.w,
height: 250.h,
color: Colors.blue,
),
// 绝对定位:右上角,负偏移让它一半悬在盒子外
Positioned(
top: -10,
right: -10,
child: Container(
padding: EdgeInsets.all(6.w),
color: Colors.red,
child: Text(
"角标",
style: TextStyle(color: Colors.white),
),
),
),
// 普通子组件,受 Stack.alignment 控制居中
Text(
"中间文字",
style: TextStyle(color: Colors.white, fontSize: 20.sp),
),
],
),
),
SizedBox(height: 32.h),
const _SectionTitle('Demo2:Positioned.fill 蒙层'),
Center(
child: Stack(
children: [
// 底层组件撑起 Stack 的尺寸(250x250)
Container(width: 250.w, height: 250.h, color: Colors.grey),
// Positioned.fill 填满整个 Stack,做半透明蒙层
Positioned.fill(
child: Container(
color: Colors.black54,
child: Center(
child: Text(
"蒙层",
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
),
SizedBox(height: 32.h),
const _SectionTitle('坑①:全部 Positioned → Stack 尺寸异常'),
Text(
'全部子组件都是 Positioned 时,Stack 尺寸不由内容决定,'
'而是直接取父约束最大值。套上 SizedBox 有界约束后,'
'Stack 会占满 300x60(而非收缩到内容大小);'
'若裸放在 Column 这种无界高度里,debug 模式会直接断言崩溃,'
'整个 body 都画不出来——这就是之前页面只剩标题的原因。',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
// 有界约束 + 全 Positioned:Stack 占满 SizedBox 给的 300x60
SizedBox(
width: 300.w,
height: 60.h,
child: ColoredBox(
color: Color(0xFFF5F7FA),
child: Stack(
children: [
Positioned(top: 10, left: 10, child: Text("A")),
],
),
),
),
SizedBox(height: 32.h),
const _SectionTitle('坑①修复方案1:SizedBox 固定宽高'),
SizedBox(
width: 300.w,
height: 80.h,
child: ColoredBox(
color: Color(0xFFF5F7FA),
child: Stack(
children: [
Positioned(top: 10, left: 10, child: Text("A")),
],
),
),
),
SizedBox(height: 32.h),
const _SectionTitle('坑①修复方案2:非 Positioned 底层组件撑起尺寸'),
Stack(
children: [
// 非 Positioned 底层组件:由它决定 Stack 尺寸
ColoredBox(
color: Color(0xFFE8E8E8),
child: SizedBox(width: 300.w, height: 80.h),
),
Positioned(top: 10, left: 10, child: Text("A")),
],
),
],
),
),
);
}
}
/// 区块标题
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: 12.h),
child: Text(
title,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
);
}
}
+238
View File
@@ -0,0 +1,238 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// Text 文本组件各种细节演示页
///
/// 一、两种使用方式:
/// 1. 直接运行本页面:runApp(const MaterialApp(home: TextDemoPage()))
/// 2. 复制某段核心代码到任意 Scaffold 的 body 中。
///
/// 二、Text 全家桶速记:
/// 单样式文字 → Text
/// 富文本拼接 → Text.rich + TextSpan
/// 全局默认样式 → DefaultTextStyle(MaterialApp 自带,Text 会继承)
///
/// 三、Flutter 3.x 注意:
/// textScaleFactor 已废弃 → 用 textScaler / MediaQuery.textScalerOf(context)
/// 颜色的 withOpacity 已废弃 → 用 withValues(alpha: x)
/// ============================================================
class TextDemoPage extends StatelessWidget {
const TextDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Text 文本组件 Demo'), backgroundColor: Colors.blue),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _SectionTitle('1. 基础样式:fontSize / color / fontWeight / fontStyle'),
Text(
'默认样式',
style: TextStyle(fontSize: 16.sp),
),
SizedBox(height: 8.h),
Text(
'加粗 红色 斜体',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: Color(0xFFE53935),
fontStyle: FontStyle.italic,
),
),
SizedBox(height: 24.h),
const _SectionTitle('2. 对齐 textAlign(需有宽度约束)'),
Container(
width: 300.w,
color: const Color(0xFFF5F7FA),
child: const Column(
// 关键:stretch 让每个子组件横向拉满,textAlign 才有空间生效
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('左对齐', textAlign: TextAlign.left),
Text('居中对齐', textAlign: TextAlign.center),
Text('右对齐', textAlign: TextAlign.right),
],
),
),
SizedBox(height: 24.h),
const _SectionTitle('3. 截断:maxLines + overflow(三件套必须配齐)'),
// 要点:Text 必须有【有界宽度】溢出才生效,所以外面套了固定宽的 Container
Container(
width: 300.w,
color: const Color(0xFFF5F7FA),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ellipsis:这是一段非常非常非常长的文字,用来演示溢出省略号的效果,看看能不能截断显示',
maxLines: 1,
overflow: TextOverflow.ellipsis, // 末尾加省略号
),
SizedBox(height: 8.h),
Text(
'clip:这是一段非常非常非常长的文字,用来演示溢出直接裁剪的效果,看看能不能截断显示',
maxLines: 1,
overflow: TextOverflow.clip, // 直接裁掉,不加省略号
),
SizedBox(height: 8.h),
Text(
// visible:文字真·溢出,直接画出灰色盒子边界(Container 默认不裁剪)
'softWrap:false:文字横向溢出灰色盒子边界,直接画到盒子外面去',
softWrap: false, // 禁止自动换行
overflow: TextOverflow.visible,
),
],
),
),
SizedBox(height: 24.h),
const _SectionTitle('4. 行高 height(对比默认行高)'),
Container(
width: 300.w,
color: const Color(0xFFF5F7FA),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'默认行高\n第二行内容',
style: TextStyle(fontSize: 16.sp),
),
SizedBox(height: 8.h),
Text(
'height:1.2 紧凑\n第二行内容',
style: TextStyle(fontSize: 16.sp, height: 1.2),
),
SizedBox(height: 8.h),
Text(
'height:2 松散\n第二行内容',
style: TextStyle(fontSize: 16.sp, height: 2),
),
],
),
),
SizedBox(height: 24.h),
const _SectionTitle('5. 字间距 letterSpacing / 词间距 wordSpacing'),
Text(
'字间距 4:Flutter 框架真棒',
style: TextStyle(fontSize: 16.sp, letterSpacing: 4),
),
SizedBox(height: 8.h),
Text(
'词间距 8:Flutter Framework Great',
style: TextStyle(fontSize: 16.sp, wordSpacing: 8),
),
SizedBox(height: 24.h),
const _SectionTitle('6. 富文本:Text.rich + TextSpan'),
// 正确写法:recognizer 放 StatefulWidget 里创建并 dispose
const _AgreementRichText(),
SizedBox(height: 24.h),
const _SectionTitle('7. 字体缩放 textScaler(Flutter 3 新写法)'),
// 旧写法 textScaleFactor 已废弃;textScaler 按倍数放大
Text(
'线性放大 1.5 倍',
style: TextStyle(fontSize: 14.sp),
textScaler: TextScaler.linear(1.5),
),
SizedBox(height: 8.h),
// 跟随系统设置:MaterialApp 已按系统字体缩放,也可显式读取
Builder(
builder: (context) {
final ts = MediaQuery.textScalerOf(context);
return Text(
'当前系统缩放:${ts.scale(14).toStringAsFixed(1)}px(原本14px)',
style: TextStyle(fontSize: 14.sp),
);
},
),
],
),
),
);
}
}
/// 区块标题
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: 12.h),
child: Text(
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
);
}
}
/// 富文本协议段落 —— StatefulWidget 版本
/// 关键点:TapGestureRecognizer 必须在 dispose() 里释放,防止内存泄漏
class _AgreementRichText extends StatefulWidget {
const _AgreementRichText();
@override
State<_AgreementRichText> createState() => _AgreementRichTextState();
}
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() {
// 关键:释放 recognizer,与 createState 一一对应
_agreementRecognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: [
TextSpan(
text: '已阅读并同意',
style: TextStyle(fontSize: 14.sp, color: Color(0xFF888888)),
),
TextSpan(
text: '《用户协议》',
style: TextStyle(
fontSize: 14.sp,
color: Color(0xFF3B82F6),
fontWeight: FontWeight.bold,
),
recognizer: _agreementRecognizer,
),
],
),
);
}
}
+252
View File
@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/theme/brand_theme.dart';
/// ============================================================
/// 【ThemeDemoPage】品牌换肤 Demo(两级主题模型)
///
/// 一、两级模型(模型与配置已解耦,见 core/theme/):
/// core/theme/brand_theme.dart ← 模型(BrandTheme/PageTheme/PageLevel)
/// + 注册表 brandThemes
/// core/theme/brands/xxx_theme.dart← 每品牌一个文件,只有具体配置
/// 一级 = 品牌:seedColor 生成整套 Material 色板(全 App 共享);
/// 二级 = 页面细节(PageTheme):按页面层级(home/detail/result)
/// 区分背景色 / 头部色 / 强调色 / 专属 banner / 标题文案。
/// 页面只声明"我是哪个层级",细节从品牌对象按层级查出来:
/// 换品牌 → 一级色板全换 + 二级细节全换(一套代码)
///
/// 二、本页怎么实现切换(不依赖全局 MaterialApp):
/// 页面根部用 Theme(data:) 局部覆盖——ThemeData 以全局为基底,
/// 局部 Theme 再套一层,页面内组件(AppBar/Button/进度条)都从
/// 局部主题取色。真实项目里把"_brand"换成 Riverpod Provider +
/// MaterialApp 的 theme,切换点就移到根层(见注释)。
///
/// 三、资源(图片)怎么做:
/// 真实项目 PageTheme.banner 存资源路径(如 assets/img/banner_sky.png),
/// 页面 Image.asset(page.banner) 即可跟随换图。本 demo 没有两套图片
/// 资源,用"强调色渐变块"模拟 banner 占位(代码里标注了替换点)。
///
/// 四、高频坑:
/// 1. colorSchemeSeed 生成的 primary 不是精确色值(Material 会调和),
/// 要求精确用 ColorScheme.fromSeed 后手动覆盖 primary;
/// 2. 局部 Theme(data:) 必须包在 Scaffold 外面,AppBar 才能取到;
/// 3. PNG 位图不跟主题着色,能矢量(IconData)就别放多份图。
/// ============================================================
class ThemeDemoPage extends StatefulWidget {
const ThemeDemoPage({super.key});
@override
State<ThemeDemoPage> createState() => _ThemeDemoPageState();
}
class _ThemeDemoPageState extends State<ThemeDemoPage> {
/// 当前品牌(真实项目 → Riverpod Provider + shared_preferences 持久化)
BrandTheme _brand = brandThemes.first;
@override
Widget build(BuildContext context) {
// 局部主题覆盖:colorSchemeSeed 换 seed = 页面内所有 Material
// 组件自动换色(真实项目这里换成 MaterialApp.theme)
return Theme(
data: ThemeData(colorSchemeSeed: _brand.seedColor),
child: Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
title: Text('品牌换肤 Demo(${_brand.name})'),
),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ---------- 品牌切换 ----------
Wrap(
spacing: 8,
children: [
for (final b in brandThemes) // 注册表:新增品牌自动出现
ChoiceChip(
label: Text(b.name),
selected: _brand.name == b.name,
onSelected: (_) => setState(() => _brand = b),
),
],
),
SizedBox(height: 16.h),
_buildGlobalCard(),
SizedBox(height: 16.h),
_buildSectionTitle('二级:页面细节(按层级,随品牌联动)'),
SizedBox(height: 8.h),
// 三个层级各渲染一个"页面骨架预览"
_PagePreview(
level: 'home(任务 tab)',
page: _brand.pages[PageLevel.home]!,
),
_PagePreview(
level: 'detail(子详情页)',
page: _brand.pages[PageLevel.detail]!,
),
_PagePreview(
level: 'result(答题结果页)',
page: _brand.pages[PageLevel.result]!,
),
],
),
),
),
);
}
/// 一级:全局色板效果区(Material 组件自动跟随 seed)
Widget _buildGlobalCard() {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('一级:全局色板(seed: ${_brand.seedColor})'),
SizedBox(height: 8.h),
// seed 色与生成的 primary 对比
Row(
children: [
_colorChip('seed', _brand.seedColor),
SizedBox(width: 8.w),
_colorChip('primary', scheme.primary),
],
),
SizedBox(height: 12.h),
// Material 组件不用改代码,seed 一变自动换色
FilledButton(onPressed: () {}, child: const Text('跟随主题的按钮')),
SizedBox(height: 12.h),
LinearProgressIndicator(
value: 0.6,
backgroundColor: scheme.surfaceContainerHighest,
),
],
),
);
}
Widget _colorChip(String label, Color color) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF3B82F6),
),
);
}
}
// ==================== 页面骨架预览(演示二级细节怎么被消费) ====================
class _PagePreview extends StatelessWidget {
const _PagePreview({required this.level, required this.page});
/// 本页属于哪个层级(页面只声明层级,细节全从主题查)
final String level;
final PageTheme page;
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(bottom: 12.h),
decoration: BoxDecoration(
color: page.bg, // ← 二级:背景
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 模拟 AppBar 条(真实页面是 Scaffold.appBar)
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
decoration: BoxDecoration(
color: page.headerBg, // ← 二级:头部色
borderRadius: BorderRadius.vertical(top: Radius.circular(12.r)),
),
child: Row(
children: [
const Icon(Icons.arrow_back_ios_new,
color: Colors.white, size: 14),
SizedBox(width: 6.w),
Text(
page.title, // ← 二级:标题文案
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
),
Padding(
padding: EdgeInsets.all(12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// banner 占位:真实项目这里替换成 Image.asset(page.banner!)
// (banner 为 null 的层级显示"本层无专属图")
Container(
width: double.infinity,
height: 44.h,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [page.accent, page.headerBg],
),
borderRadius: BorderRadius.circular(8.r),
),
alignment: Alignment.center,
child: Text(
page.banner ?? '本层无专属 banner',
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
SizedBox(height: 8.h),
Text(
'层级:$level\n背景 ${page.bg} · 头部 ${page.headerBg} · 强调 ${page.accent}',
style: TextStyle(
fontSize: 12.sp,
height: 1.5,
color: const Color(0xFF666666),
),
),
SizedBox(height: 8.h),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: page.accent, // ← 二级:强调色
),
onPressed: () {},
child: const Text('本页主按钮'),
),
],
),
),
],
),
);
}
}
+187
View File
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// Wrap 自动换行布局演示页
///
/// 一、两种使用方式:
/// 1. 直接运行本页面:runApp(const MaterialApp(home: WrapDemoPage()))
/// 2. 复制 body 核心代码到任意 Scaffold 的 body 中(见下方)。
///
/// 二、作用:
/// Row 一行排不下就直接溢出报错;Wrap 空间不够时会【自动换行】,
/// 标签流、搜索历史标签这类数量不固定的场景必用。
///
/// 三、核心属性:
/// direction : 排布方向。Axis.horizontal 水平排布(默认);Axis.vertical 垂直排布
/// spacing : 同一行内 item 之间的水平间距
/// runSpacing: 行与行之间的垂直间距(run 是"行/批"的意思)
///
/// 四、对比记忆:
/// Row : 横向排列,溢出直接报错,不会换行
/// Wrap : 横向排列,空间不足自动换行
/// ============================================================
class WrapDemoPage extends StatelessWidget {
const WrapDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Wrap 标签流 Demo')),
// ---------- 下方即为核心代码,可直接复制到 Scaffold body ----------
body: Padding(
padding: EdgeInsets.all(16.w), // 不写const,每次build会新建对象,产生不必要开销
child: Wrap(
spacing: 24, // 同一行 item 水平间距
runSpacing: 20, // 上下两行之间的距离
children: [
// ---- 最简单的 Chip:只有 label(必填)----
const Chip(label: Text("Vue")),
// ---- 带头像 + 边框 + 圆角胶囊形状 ----
Chip(
label: const Text("React"),
avatar: Icon(Icons.tag, size: 18.w), // 标签前面的小图标
backgroundColor: const Color(0xFFE3F2FD), // 背景色
labelStyle: TextStyle(color: Colors.black, fontSize: 14.sp), // 文字样式
side: const BorderSide(color: Color(0xFF64B5F6)), // 边框
shape: const StadiumBorder(), // 全圆角胶囊形状
labelPadding: EdgeInsets.symmetric(horizontal: 6.w), // 文字内边距
),
// ---- 可删除的 Chip:右上角出小叉号,点击触发 onDeleted ----
Chip(
label: const Text("Flutter"),
avatar: Icon(Icons.rocket_launch, size: 18.w),
deleteIcon: Icon(Icons.close, size: 16.w), // 自定义叉号图标
backgroundColor: Colors.blue[100],
labelStyle: const TextStyle(color: Colors.black),
shape: StadiumBorder(),
onDeleted: () => print("删除 Flutter"), // 点击叉号回调
),
// ---- 可点击的 Chip:点击整个标签触发 onPressed ----
// Flutter 3.x 注意:Chip 已移除 onPressed,可点击请用 ActionChip
ActionChip(
label: const Text("UniApp"),
avatar: Icon(Icons.smartphone, size: 18.w),
backgroundColor: const Color(0xFFFCE4EC),
elevation: 2, // 阴影高度
shadowColor: Colors.pink.shade100, // 阴影颜色
padding: EdgeInsets.all(6.w), // Chip 整体内边距
onPressed: () => print("点击 UniApp"), // 点击标签回调
),
// ---- 选中态 Chip:selected 为 true 时显示对勾 ----
// Flutter 3.x 注意:Chip 已移除 selected,选中态请用 FilterChip
FilterChip(
label: const Text("JavaScript"),
selected: true, // 选中态
selectedColor: const Color(0xFF4CAF50), // 选中时的背景色
checkmarkColor: Colors.white, // 对勾颜色
labelStyle: const TextStyle(color: Colors.white),
shape: const StadiumBorder(),
onSelected: (_) {}, // FilterChip 必填回调:选中状态变化时触发
),
// ---- 深色背景 + 自定义删除图标颜色 ----
Chip(
label: const Text("Dart语言"),
avatar: Icon(Icons.code, size: 18.w, color: Colors.white),
backgroundColor: const Color(0xFF263238), // 深色背景
deleteIcon: Icon(Icons.cancel, size: 16.w, color: Colors.white70),
labelStyle: const TextStyle(color: Colors.white),
shape: const StadiumBorder(),
onDeleted: () => print("删除 Dart语言"),
),
// ---- 使用 Material 3 默认样式的 Chip ----
const Chip(label: Text("前端开发")),
// ===== 分割线:下面是像素级可控的自绘标签 CustomTag =====
Divider(height: 32.h),
const CustomTag(
label: "精准UI",
icon: Icons.brush,
iconColor: Color(0xFF3B82F6),
onDeleted: null, // 无删除按钮的纯标签
),
CustomTag(
label: "可删除标签",
icon: Icons.rocket_launch,
onDeleted: () => print("删除 可删除标签"),
),
],
),
),
);
}
}
/// 像素级可控的自绘标签:高度/间距/圆角/边框/点击反馈全手写
/// 比标准 Chip 灵活,适合设计稿要求精确(固定高度、精确间距)的场景
class CustomTag extends StatelessWidget {
const CustomTag({
super.key,
required this.label,
this.icon,
this.iconColor = const Color(0xFF3B82F6),
this.onDeleted,
this.onTap,
});
final String label;
final IconData? icon; // 可选的标签前图标
final Color iconColor;
final VoidCallback? onDeleted; // 点击叉号回调;传 null 则不显示叉号
final VoidCallback? onTap; // 点击整个标签回调
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent, // 消除默认白色背景
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,
color: Color(0xFF333333),
height: 1, // 去掉文字默认行高,避免撑高容器
),
),
if (onDeleted != null) ...[
SizedBox(width: 2.w), // 文字与叉号精确 2px
GestureDetector(
onTap: onDeleted,
child: Icon(
Icons.close,
size: 12.w,
color: Color(0xFF999999),
),
),
],
],
),
),
),
);
}
}
+399
View File
@@ -0,0 +1,399 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/storage/stores/wrong_question_store.dart';
/// ============================================================
/// 【WrongFlowDemoPage】接口 + Isar + watch 完整链路 Demo
///
/// 一、本页演示的三层协作(回答"现在不都调接口吗"):
/// 接口(远端)→ 上报答题结果,可能失败、可能慢
/// Isar(本地)→ 错题本快照,离线也能看
/// watch(响应式)→ 本地库一变,错题列表自动刷新
/// 流程:答错 → ① 上报接口(失败也继续)→ ② 写本地错题本
/// → ③ watch 自动推送 → 列表自动更新(零手动刷新)
///
/// 二、为什么接口失败本地仍记录(离线兜底):
/// 学生端弱网是常态。错题是"本地刚需数据",先落库先能用;
/// 联网后再增量同步回服务端(真实项目用 sync 队列,
/// 错题加 isSynced 字段标记,见注释,本例为演示未做)。
///
/// 三、对比"纯接口 + 手动刷新"的做法:
/// 不 watch 就得:答完错题 → 手动通知错题 tab 刷新(事件总线/
/// 页面重建)。watch 让 UI 直接订阅数据库,写入和展示彻底解耦。
///
/// 四、高频坑:
/// 1. watch 只在 native 可用(web 空实现),本页 kIsWeb 降级提示;
/// 2. 写库必须在 writeTxn 内(store 已封装);
/// 3. 接口上报和本地写入是两个独立步骤,别把本地写入放接口
/// onError 里——那叫"只有失败才记录",这里"先落库兜底"。
/// ============================================================
class WrongFlowDemoPage extends StatefulWidget {
const WrongFlowDemoPage({super.key});
@override
State<WrongFlowDemoPage> createState() => _WrongFlowDemoPageState();
}
class _WrongFlowDemoPageState extends State<WrongFlowDemoPage> {
/// 题库:(题干, 选项, 正确答案索引)
static const List<(String, List<String>, int)> _bank = [
('7 × 8 = ?', ['54', '56', '58', '63'], 1),
('Flutter 官方推荐的状态管理?', ['Provider', 'Riverpod', 'BLoC', 'GetX'], 1),
('go_router 登录守卫配在哪个参数?', ['routes', 'redirect', 'initialLocation', 'routes'], 1),
];
int _qIndex = 0; // 当前题目索引
int? _selected; // 已选选项
bool? _isRight; // 对/错
bool _reporting = false; // 接口上报中
String _log = ''; // 链路日志
String get _question => _bank[_qIndex].$1;
List<String> get _options => _bank[_qIndex].$2;
int get _rightIndex => _bank[_qIndex].$3;
@override
void dispose() {
super.dispose();
}
/// 答题:对 → 不记录;错 → 接口上报 + 本地错题(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]);
// ② 写本地错题本:watch 自动推送 → 下方列表自动更新
await WrongQuestionStore.instance.add(
questionId: 100 + _qIndex,
title: _question,
wrongAnswer: _options[index],
);
_appendLog('💾 已写入本地错题本 → watch 自动刷新列表');
}
// 答完切下一题
if (!mounted) return;
setState(() {
_qIndex = (_qIndex + 1) % _bank.length;
_selected = null;
_isRight = null;
});
}
/// ① 接口上报答题结果(jsonplaceholder mock,模拟真实上报)
Future<void> _reportToServer({required String wrongAnswer}) async {
setState(() => _reporting = true);
try {
await DioClient.instance.post(
'/posts',
data: {
'title': _question,
'wrongAnswer': wrongAnswer,
'source': 'student_app',
},
);
_appendLog('📡 接口上报成功(服务端已记录错题)');
} on ApiException catch (e) {
// 接口失败不阻断:本地仍要记,联网后再同步
_appendLog('📡 接口上报失败:${e.message}');
_appendLog(' ↳ 本地仍记录,联网后增量同步(sync 队列)');
} finally {
if (mounted) setState(() => _reporting = false);
}
}
/// 模拟断网:跳过接口,只写本地(演示离线兜底)
Future<void> _recordOffline() async {
if (_reporting) return;
await WrongQuestionStore.instance.add(
questionId: 999,
title: '离线练习:${_question}',
wrongAnswer: '(断网场景,仅本地记录)',
);
_appendLog('📴 断网:跳过接口,仅写本地错题本 → watch 自动刷新');
}
void _appendLog(String line) {
if (!mounted) return;
setState(() => _log = '$line\n$_log');
}
@override
Widget build(BuildContext context) {
// Web 降级:Isar 仅 native 可用
if (kIsWeb) {
return const Scaffold(
body: Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text(
'本实例依赖本地数据库(Isar),不支持 Web 调试\n请在 Android/iOS/桌面端查看',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, height: 1.8),
),
),
),
);
}
return Scaffold(
appBar: AppBar(title: const Text('答题 → 错题本 链路 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTipCard(),
SizedBox(height: 12.h),
_buildQuestionCard(),
SizedBox(height: 8.h),
OutlinedButton.icon(
onPressed: _recordOffline,
icon: const Icon(Icons.cloud_off, size: 18),
label: const Text('模拟断网:仅本地记录'),
),
SizedBox(height: 12.h),
const Text(
'链路日志(接口 → 本地 → watch)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF3B82F6),
),
),
SizedBox(height: 8.h),
Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(10.r),
),
child: SelectableText(
_log.isEmpty ? '(操作后这里显示完整链路日志)' : _log,
style: TextStyle(
fontSize: 12.sp,
height: 1.6,
color: const Color(0xFFE2E8F0),
),
),
),
SizedBox(height: 12.h),
const Text(
'错题本(watch 自动刷新,零手动操作)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF3B82F6),
),
),
SizedBox(height: 8.h),
_buildWatchList(),
],
),
),
);
}
Widget _buildTipCard() {
return Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(12.r),
),
child: const Text(
'链路:答错 → ① 接口上报(失败也继续)→ ② 写本地错题本 '
'→ ③ watch 自动推送 → 下方列表自动更新。'
'点选项答题,或点下方按钮模拟断网场景。',
style: TextStyle(fontSize: 13, color: Color(0xFF3B82F6)),
),
);
}
/// 答题卡:题干 + 选项
Widget _buildQuestionCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'第 ${_qIndex + 1} 题',
style: const TextStyle(fontSize: 12, color: Color(0xFF999999)),
),
SizedBox(height: 4.h),
Text(
_question,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
SizedBox(height: 12.h),
for (int i = 0; i < _options.length; i++) ...[
_buildOption(i),
if (i < _options.length - 1) SizedBox(height: 8.h),
],
SizedBox(height: 8.h),
if (_selected != null)
Text(
_isRight == true ? '✅ 正确,不记错题' : '❌ 已自动记入错题本',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: _isRight == true
? const Color(0xFF22C55E)
: const Color(0xFFEF4444),
),
),
],
),
);
}
Widget _buildOption(int index) {
final isSelected = _selected == index;
final isRight = isSelected && _isRight == true;
final isWrong = isSelected && _isRight == false;
return InkWell(
onTap: _reporting ? null : () => _onPick(index),
borderRadius: BorderRadius.circular(8.r),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
decoration: BoxDecoration(
color: isRight
? const Color(0xFFE8F7EE)
: isWrong
? const Color(0xFFFDECEA)
: Colors.white,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: isRight
? const Color(0xFF22C55E)
: isWrong
? const Color(0xFFEF4444)
: const Color(0xFFE5E7EB),
),
),
child: Row(
children: [
Icon(
isRight
? Icons.check_circle
: isWrong
? Icons.cancel
: Icons.radio_button_unchecked,
size: 18,
color: isRight
? const Color(0xFF22C55E)
: isWrong
? const Color(0xFFEF4444)
: const Color(0xFF9CA3AF),
),
SizedBox(width: 8.w),
Text(
_options[index],
style: const TextStyle(fontSize: 14, color: Color(0xFF333333)),
),
],
),
),
);
}
/// 错题本:watch 订阅,任何写入自动刷新
Widget _buildWatchList() {
return StreamBuilder<List<dynamic>>(
stream: WrongQuestionStore.instance.watchAll(),
builder: (context, snapshot) {
final items = snapshot.data ?? const <dynamic>[];
if (items.isEmpty) {
return Container(
padding: EdgeInsets.all(24.h),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: const Text(
'(错题本为空,答错一题试试)',
style: TextStyle(color: Color(0xFF999999)),
),
);
}
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
children: [
for (int i = 0; i < items.length; i++) ...[
_buildWrongItem(items[i]),
if (i < items.length - 1)
const Divider(height: 1, color: Color(0xFFF0F2F5)),
],
],
),
);
},
);
}
Widget _buildWrongItem(dynamic item) {
final title = item.title as String? ?? '无题干';
final wrongAnswer = item.wrongAnswer as String? ?? '';
return Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
child: Row(
children: [
const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 18),
SizedBox(width: 8.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF333333),
),
),
Text(
'我的答案:$wrongAnswer',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF999999),
),
),
],
),
),
],
),
);
}
}