flutter架 构项目基础
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('返回上一页'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('重置'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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("登录"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, '女'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()}';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('本页主按钮'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user