1243 lines
40 KiB
Dart
1243 lines
40 KiB
Dart
/// ============================================================
|
||
/// 【CompoundQuestionPanel】复合题浮动面板
|
||
/// (1:1 迁移 Vue components/CompoundQuestion/CompoundQuestionNew.vue)
|
||
///
|
||
/// 面板本体基于 PersistentSnapSheet(smooth_sheets 封装)的常驻多停靠面板:
|
||
/// 1. 固定在页面底部的非模态浮层,三档停靠点 18% / 50% / 75% 屏高,
|
||
/// 默认中档;整个面板区域可自由拖拽,松手自动吸附最近档,
|
||
/// 点击顶部把手循环切档;
|
||
/// 2. 顶部 tabbar 横滑,每个子题一个圆点(编辑蓝点=未答;查看
|
||
/// 绿=对/红=错/灰=未答),点击切换并滚动到该项;
|
||
/// 3. 下方 PageView 左右滑动切子题,每子题独立滚动,切回顶部;
|
||
/// 子题列表滚到顶后继续下拉可拖动面板(onlyFromTop 联动);
|
||
/// 4. 子题内容:子题干 / objOptions / AnswerView / 填空输入框 /
|
||
/// 图片作答 Upload / 主观题图片 Upload / 解析盒;
|
||
/// 5. 交互事件(与 Vue emit 同名同义):
|
||
/// - onMyAnswer(index, child) → update:myAnswer
|
||
/// - onSubSwitch(index, fromChild) → update:handleSubSwitch
|
||
/// - onActiveSubIndexChanged(index) → update:activeSubIndex
|
||
/// - onFillBlankImgChange(index, images) → FilllankImgChange
|
||
/// - onPanelHeightChanged(heightPx) → update:panelHeight
|
||
///
|
||
/// 说明:
|
||
/// - 组件内部已含 SheetViewport,父级直接放进 Stack 即可,不要再包 Positioned;
|
||
/// - 父级需以 ValueKey(detail.id) 挂载,切大题时整体重建,
|
||
/// 子题间切换由 activeSubIndex 入参回灌(didUpdateWidget);
|
||
/// - Signature 已废弃:多空图片作答统一用 Upload(aiGrading, maxCount:1)。
|
||
/// ============================================================
|
||
library;
|
||
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:new_learn/core/components/audio_player_card.dart';
|
||
import 'package:new_learn/core/components/media_pause_scope.dart';
|
||
import 'package:new_learn/core/components/persistent_snap_sheet.dart';
|
||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||
import 'package:new_learn/core/components/upload.dart';
|
||
import 'package:new_learn/core/components/video_player_view.dart';
|
||
import 'package:new_learn/core/utils/img_url.dart';
|
||
import 'package:new_learn/core/utils/question/question_parser.dart';
|
||
import 'package:smooth_sheets/smooth_sheets.dart';
|
||
|
||
import '../models/question_data_adapter.dart';
|
||
import '../models/question_model.dart';
|
||
import 'answer_view.dart';
|
||
import 'exercise_styles.dart';
|
||
import 'obj_options.dart';
|
||
|
||
/// 对应 Vue props.mode
|
||
enum CompoundMode { edit, view }
|
||
|
||
/// 对应 defineExpose 的 getChildData() 返回值 {index, data}
|
||
class CompoundChildData {
|
||
const CompoundChildData({required this.index, required this.data});
|
||
|
||
final int index;
|
||
final QuestionModel data;
|
||
}
|
||
|
||
typedef CompoundMyAnswerCallback = void Function(int index, QuestionModel data);
|
||
typedef CompoundSubSwitchCallback = void Function(int index, QuestionModel data);
|
||
typedef CompoundActiveSubCallback = void Function(int index);
|
||
typedef CompoundFillBlankCallback = void Function(
|
||
int subIndex, List<Map<String, dynamic>> images);
|
||
typedef CompoundPanelHeightCallback = void Function(double heightPx);
|
||
|
||
class CompoundQuestionPanel extends StatefulWidget {
|
||
const CompoundQuestionPanel({
|
||
super.key,
|
||
required this.detail,
|
||
this.mode = CompoundMode.edit,
|
||
this.activeSubIndex = 0,
|
||
this.isActive = false,
|
||
this.isBottomH = true,
|
||
this.isScore = false,
|
||
this.onMyAnswer,
|
||
this.onSubSwitch,
|
||
this.onActiveSubIndexChanged,
|
||
this.onFillBlankImgChange,
|
||
this.onPanelHeightChanged,
|
||
});
|
||
|
||
/// 复合题大题数据(与 questionsList 元素同一 Map 引用)
|
||
final QuestionModel detail;
|
||
|
||
final CompoundMode mode;
|
||
|
||
/// 当前激活子题下标(外部回灌)
|
||
final int activeSubIndex;
|
||
|
||
/// 本面板所属大题是否为当前展示大题
|
||
final bool isActive;
|
||
|
||
/// 是否预留底部按钮高度(classExercises 页固定传 false)
|
||
final bool isBottomH;
|
||
|
||
/// 为 true 时不显示子题分数(classExercises 固定 true)
|
||
final bool isScore;
|
||
|
||
final CompoundMyAnswerCallback? onMyAnswer;
|
||
final CompoundSubSwitchCallback? onSubSwitch;
|
||
final CompoundActiveSubCallback? onActiveSubIndexChanged;
|
||
final CompoundFillBlankCallback? onFillBlankImgChange;
|
||
final CompoundPanelHeightCallback? onPanelHeightChanged;
|
||
|
||
@override
|
||
CompoundQuestionPanelState createState() => CompoundQuestionPanelState();
|
||
}
|
||
|
||
class CompoundQuestionPanelState extends State<CompoundQuestionPanel> {
|
||
/// 三档停靠点(屏高比例,对标原锚点 18% / 50% / 75%)
|
||
static const List<double> _snapFactors = [0.18, 0.50, 0.75];
|
||
|
||
/// 当前停靠档(点击把手循环切档用)
|
||
int _anchorIndex = 1;
|
||
|
||
/// 面板位置控制器(拖拽 / 代码切档 / 高度监听)
|
||
late final SheetController _sheetController = SheetController();
|
||
|
||
late int _tabbarIndex = _initialIndex();
|
||
late int _lastActiveIndex = _tabbarIndex;
|
||
|
||
/// swiper 动画锁(对标 isSwiperAnimating,防止 change 来回闪烁)
|
||
bool _isAnimating = false;
|
||
Timer? _animatingTimer;
|
||
|
||
late final PageController _pageController =
|
||
PageController(initialPage: _tabbarIndex);
|
||
final ScrollController _tabbarScrollController = ScrollController();
|
||
|
||
final Map<int, GlobalKey> _tabKeys = {};
|
||
final Map<int, GlobalKey<_SubPageState>> _subKeys = {};
|
||
|
||
/// 各子题独立的媒体暂停注册表(与 _subKeys 同生命周期)
|
||
final Map<int, MediaPauseRegistry> _subRegistries = {};
|
||
|
||
/// 每个子题 Upload 组件的当前图片缓存(切子题/翻页不丢,
|
||
/// 同时作为 finalizeChildAnswer 的取数来源,对标 uploadAnswerRefs)
|
||
final Map<int, List<UploadItem>> _answerUploadCache = {};
|
||
final Map<int, List<UploadItem>> _fillblankUploadCache = {};
|
||
|
||
int get _subCount {
|
||
final subs = widget.detail.subQuestions.length;
|
||
return subs > 0 ? subs : widget.detail.childs.length;
|
||
}
|
||
|
||
int _initialIndex() {
|
||
final count = widget.detail.subQuestions.length;
|
||
final max = count > 0 ? count - 1 : 0;
|
||
return widget.activeSubIndex.clamp(0, max);
|
||
}
|
||
|
||
static String _isoNow() => DateTime.now().toIso8601String();
|
||
|
||
// ===================== 生命周期 =====================
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// 对标 watch(detail, immediate) + watch(isActive, immediate)
|
||
if (widget.isActive) {
|
||
final child = widget.detail.childAt(_tabbarIndex);
|
||
if (child != null && (child.startTime == null || child.startTime!.isEmpty)) {
|
||
child.startTime = _isoNow();
|
||
}
|
||
}
|
||
_sheetController.addListener(_onSheetMetricsChanged);
|
||
// 首帧后 metrics 就绪,回传一次初始高度
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) _onSheetMetricsChanged();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant CompoundQuestionPanel oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
|
||
// 正常情况下父级以 ValueKey(detail.id) 重建;兜底清理缓存
|
||
if (!identical(widget.detail, oldWidget.detail)) {
|
||
_answerUploadCache.clear();
|
||
_fillblankUploadCache.clear();
|
||
}
|
||
|
||
// 对标 watch(isActive)
|
||
if (widget.isActive != oldWidget.isActive) {
|
||
final child = widget.detail.childAt(_tabbarIndex);
|
||
if (widget.isActive) {
|
||
if (child != null &&
|
||
(child.startTime == null || child.startTime!.isEmpty)) {
|
||
child.startTime = _isoNow();
|
||
}
|
||
} else {
|
||
if (child != null &&
|
||
child.startTime != null &&
|
||
child.startTime!.isNotEmpty &&
|
||
(child.endTime == null || child.endTime!.isEmpty)) {
|
||
_finalizeChildAnswer(_tabbarIndex);
|
||
child.endTime = _isoNow();
|
||
widget.onMyAnswer?.call(_tabbarIndex, child);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 对标 watch(activeSubIndex):外部回灌只做视觉切换,不走 handleSubSwitch
|
||
final ext = widget.activeSubIndex;
|
||
if (ext != _tabbarIndex) {
|
||
_lastActiveIndex = ext;
|
||
final child = widget.detail.childAt(ext);
|
||
if (widget.isActive && child != null) {
|
||
child.startTime = _isoNow();
|
||
}
|
||
_pauseSubMedia(_tabbarIndex);
|
||
setState(() => _tabbarIndex = ext);
|
||
if (_pageController.hasClients) {
|
||
_pageController.animateToPage(
|
||
ext,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
_scrollSubPageToTop(ext);
|
||
_isAnimating = true;
|
||
_animatingTimer?.cancel();
|
||
_animatingTimer = Timer(const Duration(milliseconds: 500), () {
|
||
_isAnimating = false;
|
||
_ensureTabVisible(ext, 0);
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_animatingTimer?.cancel();
|
||
_pageController.dispose();
|
||
_tabbarScrollController.dispose();
|
||
_sheetController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
// ===================== 对外暴露(对标 defineExpose) =====================
|
||
|
||
/// 返回当前子题的 subQuestion(startTime 已从 childs 同步)
|
||
CompoundChildData? getChildData() {
|
||
final sub = widget.detail.subAt(_tabbarIndex) ??
|
||
widget.detail.childAt(_tabbarIndex);
|
||
if (sub == null) return null;
|
||
final child = widget.detail.childAt(_tabbarIndex);
|
||
if (child != null) sub.startTime = child.startTime;
|
||
return CompoundChildData(index: _tabbarIndex, data: sub);
|
||
}
|
||
|
||
/// 暂停当前子题内所有音频/视频(对标 uni.$emit('globalMediaPlay'))
|
||
void pauseMedia() => _pauseSubMedia(_tabbarIndex);
|
||
|
||
// ===================== 面板位置 / 高度 =====================
|
||
|
||
/// SheetController 监听回调:同步当前停靠档,并把面板像素高度回传父级
|
||
void _onSheetMetricsChanged() {
|
||
final metrics = _sheetController.metrics;
|
||
if (metrics == null) return;
|
||
|
||
// 找出距当前 offset 最近的停靠档(弹性过界时也成立)
|
||
final viewportH = metrics.viewportSize.height;
|
||
var nearest = 0;
|
||
var best = double.infinity;
|
||
for (var i = 0; i < _snapFactors.length; i++) {
|
||
final diff = (viewportH * _snapFactors[i] - metrics.offset).abs();
|
||
if (diff < best) {
|
||
best = diff;
|
||
nearest = i;
|
||
}
|
||
}
|
||
_anchorIndex = nearest;
|
||
widget.onPanelHeightChanged?.call(metrics.offset);
|
||
}
|
||
|
||
/// 点击把手:循环跳到下一档(拖拽手势由 Sheet 自身接管)
|
||
void _onHeaderTap() {
|
||
final next = (_anchorIndex + 1) % _snapFactors.length;
|
||
_anchorIndex = next;
|
||
_sheetController.animateTo(SheetOffset(_snapFactors[next]));
|
||
}
|
||
|
||
// ===================== 子题切换 =====================
|
||
|
||
void _onTabTap(int i) {
|
||
if (_isAnimating) return;
|
||
if (i == _tabbarIndex) return;
|
||
_switchToByGesture(i);
|
||
}
|
||
|
||
void _onPageChanged(int i) {
|
||
if (_isAnimating) return;
|
||
if (i == _tabbarIndex) return;
|
||
_switchToByGesture(i);
|
||
}
|
||
|
||
/// 子题链接 #subquestion-N 点击 → 等同 tabbar 点击
|
||
void onStemLinkTap(int i) {
|
||
if (i < 0 || i >= _subCount || i == _tabbarIndex) return;
|
||
if (_isAnimating) return;
|
||
_switchToByGesture(i);
|
||
}
|
||
|
||
void _switchToByGesture(int target) {
|
||
_isAnimating = true;
|
||
_pauseSubMedia(_lastActiveIndex);
|
||
_handleSubSwitch(_lastActiveIndex, target);
|
||
setState(() => _tabbarIndex = target);
|
||
_pageController.animateToPage(
|
||
target,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
);
|
||
_scrollSubPageToTop(target);
|
||
widget.onActiveSubIndexChanged?.call(target);
|
||
_animatingTimer?.cancel();
|
||
_animatingTimer = Timer(const Duration(milliseconds: 300), () {
|
||
_isAnimating = false;
|
||
_ensureTabVisible(target, 0);
|
||
});
|
||
}
|
||
|
||
/// 对标 handleSubSwitch:结掉 from 子题时间并上报,开 to 子题时间
|
||
void _handleSubSwitch(int from, int to) {
|
||
if (from == to) return;
|
||
final now = _isoNow();
|
||
final fromChild = widget.detail.childAt(from);
|
||
if (fromChild != null) {
|
||
_finalizeChildAnswer(from);
|
||
fromChild.endTime = now;
|
||
widget.onSubSwitch?.call(from, fromChild);
|
||
}
|
||
widget.detail.childAt(to)?.startTime = now;
|
||
_lastActiveIndex = to;
|
||
}
|
||
|
||
/// 对标 finalizeChildAnswer:离开子题前把图片作答写回 child.userAnswer
|
||
void _finalizeChildAnswer(int index) {
|
||
final child = widget.detail.childAt(index);
|
||
final sub = widget.detail.subAt(index);
|
||
if (child == null || sub == null) return;
|
||
|
||
final isObjective = child.isObjective ||
|
||
const ['danxuanti', 'duoxuanti', 'panduanti']
|
||
.contains(sub.questionTypes);
|
||
final isBlank = sub.questionTypes == 'tiankongti';
|
||
final isUpload = !isObjective && !isBlank;
|
||
if (!isUpload) return;
|
||
|
||
final items = _answerUploadCache[index];
|
||
if (items == null) return;
|
||
final imgs = QuestionDataAdapter.uploadItemsToAnswerImgs(items)
|
||
.where((e) => '${e['Url'] ?? ''}'.isNotEmpty)
|
||
.toList();
|
||
if (imgs.isNotEmpty) {
|
||
child.userAnswer = jsonEncode(imgs);
|
||
}
|
||
}
|
||
|
||
void _pauseSubMedia(int index) {
|
||
// 直接调注册表,避免切子题动画期间递归遍历 element 树
|
||
_subRegistries[index]?.pauseAll();
|
||
}
|
||
|
||
void _scrollSubPageToTop(int index) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
_subKeys[index]?.currentState?.scrollToTop();
|
||
});
|
||
}
|
||
|
||
void _ensureTabVisible(int index, int delayMs) {
|
||
Future.delayed(Duration(milliseconds: delayMs), () {
|
||
if (!mounted) return;
|
||
final ctx = _tabKeys[index]?.currentContext;
|
||
if (ctx != null && ctx.mounted) {
|
||
Scrollable.ensureVisible(
|
||
ctx,
|
||
alignment: 0,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===================== 作答回调(子页 → 面板 → 父级) =====================
|
||
|
||
/// 对标 handleSelect
|
||
void handleSelect(int index, Map<String, dynamic> val) {
|
||
if (widget.mode != CompoundMode.edit) return;
|
||
final child = widget.detail.childAt(index);
|
||
if (child == null) return;
|
||
|
||
if (val.containsKey('userAnswer')) {
|
||
final raw = val['userAnswer'];
|
||
child.userAnswer = raw != null && '$raw'.isNotEmpty ? '$raw' : '';
|
||
_emitMyAnswer(index, child);
|
||
return;
|
||
}
|
||
|
||
// 老逻辑兜底(迁移后的 ObjOptions 统一走 userAnswer,此分支基本不触发)
|
||
final label = '${val['optionLabel'] ?? ''}';
|
||
var currentAnswers =
|
||
child.userAnswer.isNotEmpty ? child.userAnswer.split(',') : <String>[];
|
||
if (child.questionTypeName == '多选题') {
|
||
if (currentAnswers.contains(label)) {
|
||
currentAnswers = currentAnswers.where((a) => a != label).toList();
|
||
} else {
|
||
currentAnswers.add(label);
|
||
}
|
||
currentAnswers.sort();
|
||
} else {
|
||
currentAnswers = [label];
|
||
}
|
||
child.userAnswer = currentAnswers.join(',');
|
||
_emitMyAnswer(index, child);
|
||
}
|
||
|
||
/// 对标 onInputBlank
|
||
void onInputBlank(int index, String value) {
|
||
if (widget.mode != CompoundMode.edit) return;
|
||
final child = widget.detail.childAt(index);
|
||
if (child == null) return;
|
||
child.userAnswer = value;
|
||
widget.onMyAnswer?.call(index, child);
|
||
}
|
||
|
||
/// 对标 handleUploadChange(主观题图片作答,maxCount 5)
|
||
void onAnswerUploadChanged(int index, List<UploadItem> items) {
|
||
if (widget.mode != CompoundMode.edit) return;
|
||
final child = widget.detail.childAt(index);
|
||
if (child == null) return;
|
||
_answerUploadCache[index] = List<UploadItem>.from(items);
|
||
final imgs = QuestionDataAdapter.uploadItemsToAnswerImgs(items);
|
||
child.userAnswer = imgs.isNotEmpty ? jsonEncode(imgs) : '';
|
||
_emitMyAnswer(index, child);
|
||
}
|
||
|
||
/// 对标 FilllankUploadChange(多空图片作答,maxCount 1),
|
||
/// 面板本身不写 child,由父级双写 subQuestions/childs
|
||
void onFillBlankChanged(int index, List<UploadItem> items) {
|
||
_fillblankUploadCache[index] = List<UploadItem>.from(items);
|
||
widget.onFillBlankImgChange
|
||
?.call(index, QuestionDataAdapter.uploadItemsToAnswerImgs(items));
|
||
}
|
||
|
||
void _emitMyAnswer(int index, QuestionModel child) {
|
||
setState(() {});
|
||
widget.onMyAnswer?.call(index, child);
|
||
}
|
||
|
||
// ===================== Upload 缓存取数 =====================
|
||
|
||
List<UploadItem> fillblankItemsFor(int index, String rawAnswer) {
|
||
return _fillblankUploadCache.putIfAbsent(
|
||
index,
|
||
() => QuestionDataAdapter.toUploadItems(
|
||
QuestionDataAdapter.parseImageAnswerList(rawAnswer)),
|
||
);
|
||
}
|
||
|
||
List<UploadItem> answerUploadItemsFor(int index, String rawAnswer) {
|
||
return _answerUploadCache.putIfAbsent(
|
||
index,
|
||
() => QuestionDataAdapter.toUploadItems(
|
||
QuestionDataAdapter.parseImageAnswerList(rawAnswer)),
|
||
);
|
||
}
|
||
|
||
// ===================== tabbar 展示 =====================
|
||
|
||
/// 对标 getQuestionStatusClass → 圆点颜色
|
||
Color? _tabDotColor(int i) {
|
||
final child = widget.detail.childAt(i);
|
||
if (child == null) return const Color(0xFF94A5B8);
|
||
final answer = child.effectiveAnswer;
|
||
|
||
if (widget.mode == CompoundMode.edit) {
|
||
final imgs = QuestionDataAdapter.parseImageAnswerList(answer);
|
||
if (answer == '[]') return ExerciseColors.primary;
|
||
if (imgs.isEmpty && answer.isEmpty) return ExerciseColors.primary;
|
||
return null;
|
||
}
|
||
|
||
final status = child.answerStatus;
|
||
if (status != null) {
|
||
if (status == 10) return ExerciseColors.correct;
|
||
if (status == 11) return ExerciseColors.error;
|
||
// 0 未答 / 1 判题中 / 12 等其他情况,统一视为未答
|
||
return const Color(0xFF94A5B8);
|
||
}
|
||
|
||
if (answer.isEmpty) return const Color(0xFF94A5B8);
|
||
final uAns = answer.replaceAll(RegExp(r'<[^>]+>'), '').trim();
|
||
final cAns = (child.correctAnswer.isNotEmpty
|
||
? child.correctAnswer
|
||
: child.answer)
|
||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||
.trim();
|
||
return uAns == cAns ? ExerciseColors.correct : ExerciseColors.error;
|
||
}
|
||
|
||
/// 对标 formatSubIndex:无 index → "(i+1)题";去点;含"题"直接用
|
||
String _formatSubIndex(dynamic subIndex, int i) {
|
||
if (subIndex == null || '$subIndex'.isEmpty) return '${i + 1}题';
|
||
final s = '$subIndex'.replaceAll('.', '').trim();
|
||
if (s.contains('题')) return s;
|
||
return '$s题';
|
||
}
|
||
|
||
/// 对标 cutStr:截到小数点后 1 位(不四舍五入)
|
||
String _cutStr(dynamic num) {
|
||
final s = '$num';
|
||
final dot = s.indexOf('.');
|
||
if (dot == -1) return s;
|
||
return s.substring(0, dot + 2);
|
||
}
|
||
|
||
GlobalKey _tabKey(int i) =>
|
||
_tabKeys.putIfAbsent(i, () => GlobalKey());
|
||
|
||
GlobalKey<_SubPageState> _subKey(int i) =>
|
||
_subKeys.putIfAbsent(i, () => GlobalKey<_SubPageState>());
|
||
|
||
// ===================== Build =====================
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final subs = widget.detail.subQuestions;
|
||
final safeBottom = MediaQuery.viewPaddingOf(context).bottom;
|
||
|
||
// 常驻多停靠面板(拖拽 / 吸附由 PersistentSnapSheet 接管)
|
||
return PersistentSnapSheet(
|
||
snapFactors: _snapFactors,
|
||
initialOffsetIndex: 1,
|
||
controller: _sheetController,
|
||
decoration: const _PanelSheetDecoration(),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.max,
|
||
children: [
|
||
_buildHeader(),
|
||
_buildTabbar(subs),
|
||
Expanded(
|
||
child: Container(
|
||
color: Colors.white,
|
||
child: PageView.builder(
|
||
controller: _pageController,
|
||
itemCount: _subCount,
|
||
onPageChanged: _onPageChanged,
|
||
itemBuilder: (context, i) {
|
||
return MediaPauseScope(
|
||
registry: _subRegistries.putIfAbsent(
|
||
i, () => MediaPauseRegistry()),
|
||
child: _SubPage(
|
||
key: _subKey(i),
|
||
panel: this,
|
||
index: i,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
// 对标 dynamicPaddingBottom(isBottomH=false → 10.h + 安全区)
|
||
SizedBox(height: (widget.isBottomH ? 70.h : 10.h) + safeBottom),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 对标 l-floating-panel__header(30px 高,75rpx 宽把手)。
|
||
/// 拖拽手势由 Sheet 接管,这里仅保留点击循环切档。
|
||
Widget _buildHeader() {
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: _onHeaderTap,
|
||
child: SizedBox(
|
||
height: 30,
|
||
child: Center(
|
||
child: Container(
|
||
width: 37.5.w,
|
||
height: 3,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFB4C2E0),
|
||
borderRadius: BorderRadius.circular(3),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTabbar(List<QuestionModel> subs) {
|
||
final list = subs.isNotEmpty ? subs : widget.detail.childs;
|
||
// 对标 Vue scroll-view.width-100:强制撑满面板宽度,
|
||
// 子题不足一屏时标签从左侧开始排列(否则整条会被 Column 居中)
|
||
return Container(
|
||
width: double.infinity,
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
bottom: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w),
|
||
),
|
||
),
|
||
child: SingleChildScrollView(
|
||
controller: _tabbarScrollController,
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
for (var i = 0; i < list.length; i++) _buildTabItem(list[i], i),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTabItem(QuestionModel sub, int i) {
|
||
final active = i == _tabbarIndex;
|
||
final dot = _tabDotColor(i);
|
||
|
||
final scoreRaw = sub.m['score'];
|
||
final hasScore = scoreRaw != null &&
|
||
'$scoreRaw'.isNotEmpty &&
|
||
'$scoreRaw' != '0';
|
||
final showScore = hasScore && !widget.isScore;
|
||
|
||
final label = Text(
|
||
_formatSubIndex(sub.index, i),
|
||
style: TextStyle(
|
||
fontSize: 15.sp,
|
||
color: active ? ExerciseColors.primary : ExerciseColors.title,
|
||
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
|
||
),
|
||
);
|
||
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => _onTabTap(i),
|
||
child: Padding(
|
||
key: _tabKey(i),
|
||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
label,
|
||
// 对标 .tabbar-li-status::after 状态圆点
|
||
if (dot != null)
|
||
Positioned(
|
||
right: -12.w,
|
||
top: 0,
|
||
child: Container(
|
||
width: 10.w,
|
||
height: 10.w,
|
||
decoration: BoxDecoration(
|
||
color: dot,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
),
|
||
// 对标 .tabbar-li-category::after 选中下划线
|
||
if (active)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: showScore ? -20.h : -12.h,
|
||
height: 2.h,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: ExerciseColors.primary,
|
||
borderRadius: BorderRadius.circular(1.r),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (showScore)
|
||
Padding(
|
||
padding: EdgeInsets.only(top: 2.h),
|
||
child: Text(
|
||
'${_cutStr(scoreRaw)}分',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
color: ExerciseColors.title,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 子题页(swiper-item)
|
||
// ============================================================
|
||
|
||
class _SubPage extends StatefulWidget {
|
||
const _SubPage({
|
||
super.key,
|
||
required this.panel,
|
||
required this.index,
|
||
});
|
||
|
||
final CompoundQuestionPanelState panel;
|
||
final int index;
|
||
|
||
@override
|
||
State<_SubPage> createState() => _SubPageState();
|
||
}
|
||
|
||
class _SubPageState extends State<_SubPage>
|
||
with AutomaticKeepAliveClientMixin {
|
||
/// 子页独立的 SheetScrollController(滚动与面板拖拽联动),
|
||
/// 由下方 SheetScrollable 接上所属 Sheet 的 model delegate
|
||
late final SheetScrollController _scrollController = SheetScrollController();
|
||
TextEditingController? _blankController;
|
||
|
||
@override
|
||
bool get wantKeepAlive => true;
|
||
|
||
CompoundQuestionPanelState get panel => widget.panel;
|
||
int get index => widget.index;
|
||
|
||
@override
|
||
void dispose() {
|
||
_scrollController.dispose();
|
||
_blankController?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void scrollToTop() {
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.jumpTo(0);
|
||
}
|
||
}
|
||
|
||
Future<bool> _onStemTap(String url) async {
|
||
if (url.startsWith('#subquestion-')) {
|
||
final idx = int.tryParse(url.replaceFirst('#subquestion-', ''));
|
||
if (idx != null) {
|
||
panel.onStemLinkTap(idx);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
super.build(context);
|
||
|
||
final detail = panel.widget.detail;
|
||
final mode = panel.widget.mode;
|
||
final isEdit = mode == CompoundMode.edit;
|
||
final isView = mode == CompoundMode.view;
|
||
|
||
final sub = detail.subAt(index);
|
||
final child = detail.childAt(index);
|
||
if (sub == null || child == null) return const SizedBox.shrink();
|
||
|
||
final children = <Widget>[];
|
||
|
||
// ---- 子题自己的题干(sub.stem) ----
|
||
if (sub.stem.isNotEmpty) {
|
||
children.add(Padding(
|
||
padding: EdgeInsets.only(bottom: 10.h),
|
||
child: RichTextContent(
|
||
html: cleanRichText(stripOptionTable(sub.stem)),
|
||
textStyle: TextStyle(
|
||
fontSize: 16.sp,
|
||
color: const Color(0xFF333333),
|
||
height: 1.6,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
onTapUrl: _onStemTap,
|
||
),
|
||
));
|
||
}
|
||
|
||
// ---- 客观题判定(与模板条件 1:1) ----
|
||
const objectiveTypes = [
|
||
'danxuanti',
|
||
'duoxuanti',
|
||
'panduanti',
|
||
'yueduxuanzeti',
|
||
'wanxingtiankongxuanzeti',
|
||
];
|
||
final isObjectiveFlag = child.isObjective ||
|
||
objectiveTypes.contains(sub.questionTypes) ||
|
||
sub.isSelect;
|
||
final hasOptions = sub.options.isNotEmpty;
|
||
|
||
final correct = child.correctAnswer.isNotEmpty
|
||
? child.correctAnswer
|
||
: child.answer;
|
||
final myAnswer = child.effectiveAnswer;
|
||
|
||
if (hasOptions && isObjectiveFlag) {
|
||
children.add(ObjOptions(
|
||
options: sub.options,
|
||
questionType: sub.questionTypes,
|
||
mode: isEdit ? ObjOptionMode.edit : ObjOptionMode.view,
|
||
answerData: ObjOptionAnswerData(
|
||
correctAnswer: correct,
|
||
myAnswer: myAnswer,
|
||
),
|
||
onChanged: (payload) => panel.handleSelect(index, payload),
|
||
));
|
||
}
|
||
|
||
if (isView && isObjectiveFlag) {
|
||
children.add(Padding(
|
||
padding: EdgeInsets.only(bottom: 10.h),
|
||
child: ObjectiveAnswerView(
|
||
answerStatus: child.answerStatus,
|
||
userAnswer: myAnswer,
|
||
correctAnswer: correct,
|
||
),
|
||
));
|
||
} else {
|
||
_buildNonObjective(children, detail, sub, child, isEdit, isView);
|
||
}
|
||
|
||
// ---- 解析盒 ----
|
||
_appendAnalysis(children, child);
|
||
|
||
// 底部预留 140rpx
|
||
children.add(SizedBox(height: 70.h));
|
||
|
||
return SheetScrollable(
|
||
controller: _scrollController,
|
||
child: SingleChildScrollView(
|
||
controller: _scrollController,
|
||
padding: EdgeInsets.only(left: 14.w, right: 14.w, top: 12.h),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: children,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ===================== 非客观题分支 =====================
|
||
|
||
void _buildNonObjective(
|
||
List<Widget> children,
|
||
QuestionModel detail,
|
||
QuestionModel sub,
|
||
QuestionModel child,
|
||
bool isEdit,
|
||
bool isView,
|
||
) {
|
||
final answer = child.effectiveAnswer;
|
||
final images = QuestionDataAdapter.parseImageAnswerList(answer);
|
||
final isImageAnswer = images.isNotEmpty;
|
||
|
||
// 分支 1:多空图片作答填空题
|
||
// (detail.iSFilllankItemUpload == 1,或查看态且答案为图片数组)
|
||
final imageBlank = (detail.isFillblankItemUpload == 1 ||
|
||
(isView && isImageAnswer)) &&
|
||
detail.questionTypes == 'tiankongti';
|
||
|
||
if (imageBlank) {
|
||
if (isView) children.add(_buildStatusStrip(child));
|
||
// Signature 已废弃:统一用 Upload(aiGrading, maxCount:1)
|
||
children.add(Upload(
|
||
title: '上传作答',
|
||
maxCount: 1,
|
||
type: UploadType.aiGrading,
|
||
mode: isEdit ? UploadMode.edit : UploadMode.view,
|
||
initialImageList: panel.fillblankItemsFor(index, answer),
|
||
onChanged: (items) => panel.onFillBlankChanged(index, items),
|
||
));
|
||
if (isView) {
|
||
children.add(_buildAnswerTitle('正确答案'));
|
||
children.add(_buildAnswerContent(
|
||
child.correctAnswer.isNotEmpty
|
||
? child.correctAnswer
|
||
: child.answer,
|
||
ExerciseColors.correct,
|
||
));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 分支 2:SHOW_USER_ANSWER(Vue 端常量恒为 false,暂不展示)—— 略
|
||
|
||
// 分支 3:编辑态输入类填空题
|
||
if (isEdit && sub.questionTypes == 'tiankongti') {
|
||
children.add(_buildBlankTextarea(child));
|
||
return;
|
||
}
|
||
|
||
// 分支 4:查看态输入类填空题
|
||
if (isView && sub.questionTypes == 'tiankongti') {
|
||
children.add(_buildStatusStrip(child));
|
||
if (detail.isFillblankItemUpload != 1 && child.answerStatus != 0) {
|
||
children.add(SizedBox(height: 10.h));
|
||
children.add(_buildAnswerTitle('你的答案'));
|
||
children.add(_buildAnswerContent(answer, const Color(0xFF495A7A)));
|
||
}
|
||
children.add(SizedBox(height: 10.h));
|
||
children.add(_buildAnswerTitle('正确答案'));
|
||
children.add(_buildAnswerContent(
|
||
child.correctAnswer.isNotEmpty ? child.correctAnswer : child.answer,
|
||
ExerciseColors.correct,
|
||
));
|
||
return;
|
||
}
|
||
|
||
// 分支 5:无选项的主观题(上传图片作答,maxCount 5)
|
||
if (!hasOptions) {
|
||
if (isView) children.add(_buildStatusStrip(child));
|
||
children.add(Upload(
|
||
title: '上传图片作答',
|
||
maxCount: 5,
|
||
type: UploadType.aiGrading,
|
||
mode: isEdit ? UploadMode.edit : UploadMode.view,
|
||
initialImageList: panel.answerUploadItemsFor(index, answer),
|
||
onChanged: (items) => panel.onAnswerUploadChanged(index, items),
|
||
));
|
||
if (isView) {
|
||
children.add(SizedBox(height: 10.h));
|
||
children.add(_buildAnswerTitle('正确答案'));
|
||
children.add(_buildAnswerContent(
|
||
child.correctAnswer.isNotEmpty
|
||
? child.correctAnswer
|
||
: child.answer,
|
||
ExerciseColors.correct,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
bool get hasOptions {
|
||
final sub = panel.widget.detail.subAt(index);
|
||
return sub?.options.isNotEmpty ?? false;
|
||
}
|
||
|
||
/// 填空题输入框(对标 textarea.auto-height + .input-text 样式)
|
||
Widget _buildBlankTextarea(QuestionModel child) {
|
||
final controller = _blankController ??= TextEditingController(
|
||
text: child.effectiveAnswer,
|
||
);
|
||
return Container(
|
||
// 对标 width: calc(100% - 56rpx)
|
||
margin: EdgeInsets.only(left: 14.w, right: 14.w, bottom: 10.h),
|
||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.h),
|
||
decoration: BoxDecoration(
|
||
color: ExerciseColors.bg,
|
||
borderRadius: BorderRadius.circular(12.r),
|
||
border: Border.all(color: ExerciseColors.border, width: 1.w),
|
||
),
|
||
child: TextField(
|
||
controller: controller,
|
||
minLines: 1,
|
||
maxLines: null,
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
color: const Color(0xFF1D2B47),
|
||
),
|
||
onChanged: (v) => panel.onInputBlank(index, v),
|
||
decoration: InputDecoration(
|
||
isCollapsed: true,
|
||
border: InputBorder.none,
|
||
hintText: '请输入您的答案...',
|
||
hintStyle: TextStyle(
|
||
fontSize: 14.sp,
|
||
color: const Color(0xFF94A5B8),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 作答状态条(对标 .obj-options-li-view-correct/error)
|
||
Widget _buildStatusStrip(QuestionModel child) {
|
||
final status = child.answerStatus;
|
||
final isCorrect = status == 10;
|
||
final text = isCorrect
|
||
? '作答正确'
|
||
: status == 11
|
||
? '作答有误'
|
||
: status == 12
|
||
? '部分正确'
|
||
: '未作答';
|
||
final color =
|
||
isCorrect ? ExerciseColors.correct : ExerciseColors.error;
|
||
final icon = isCorrect
|
||
? '/StaticResource/2026-06-01/correct-icon1780284329572.png'
|
||
: '/StaticResource/2026-06-01/error-icon1780284292405.png';
|
||
|
||
return GradientBorderBox(
|
||
state: isCorrect ? ExerciseTileState.correct : ExerciseTileState.error,
|
||
padding: EdgeInsets.all(12.w),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(icon),
|
||
width: 20.w,
|
||
height: 20.w,
|
||
placeholder: (_, _) => SizedBox(width: 20.w, height: 20.w),
|
||
errorWidget: (_, _, _) => SizedBox(width: 20.w, height: 20.w),
|
||
),
|
||
SizedBox(width: 10.w),
|
||
Text(
|
||
text,
|
||
style: TextStyle(fontSize: 14.sp, color: color),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 对标 .answer-box-title(title-bacs.png 背景)
|
||
Widget _buildAnswerTitle(String text) {
|
||
return Container(
|
||
padding: EdgeInsets.only(right: 14.w),
|
||
decoration: BoxDecoration(
|
||
image: DecorationImage(
|
||
image: NetworkImage(
|
||
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
|
||
),
|
||
alignment: Alignment.bottomRight,
|
||
repeat: ImageRepeat.noRepeat,
|
||
),
|
||
),
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(
|
||
fontSize: 15.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: ExerciseColors.title,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 对标 .answer-box-content
|
||
Widget _buildAnswerContent(String html, Color color) {
|
||
return Container(
|
||
width: double.infinity,
|
||
margin: EdgeInsets.symmetric(vertical: 12.h),
|
||
padding: EdgeInsets.all(12.w),
|
||
decoration: BoxDecoration(
|
||
color: ExerciseColors.bg,
|
||
borderRadius: BorderRadius.circular(14.r),
|
||
),
|
||
child: RichTextContent(
|
||
html: cleanRichText(html),
|
||
textStyle: TextStyle(fontSize: 16.sp, color: color),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ===================== 解析盒(analysis-box) =====================
|
||
|
||
/// 对标 getAnalysis:
|
||
/// child.answerAnalysis?.analysis || child.analysis || child.answerAnalysis
|
||
String _getAnalysis(QuestionModel child) {
|
||
final raw = child.answerAnalysisRaw;
|
||
if (raw is Map) {
|
||
final first = '${raw['analysis'] ?? ''}';
|
||
if (first.isNotEmpty) return first;
|
||
} else if (raw != null && '$raw'.isNotEmpty) {
|
||
return '$raw';
|
||
}
|
||
if (child.analysis.isNotEmpty) return child.analysis;
|
||
if (raw != null) return '$raw';
|
||
return '';
|
||
}
|
||
|
||
void _appendAnalysis(List<Widget> children, QuestionModel child) {
|
||
final analysis = _getAnalysis(child);
|
||
final audios = child.audios;
|
||
final videos = child.videos
|
||
.where((v) => '${v['videoUrl'] ?? v['url'] ?? ''}'.isNotEmpty)
|
||
.toList();
|
||
final hasBody = cleanRichText(analysis).isNotEmpty;
|
||
final hasAudio = audios.isNotEmpty;
|
||
final hasVideo = videos.isNotEmpty;
|
||
|
||
// 对标 v-if:cleanRichText(getAnalysis) || audios.length || videos[0].videoUrl
|
||
if (!hasBody && !hasAudio && !hasVideo) return;
|
||
|
||
final content = <Widget>[];
|
||
if (hasBody) {
|
||
content.add(Padding(
|
||
padding: EdgeInsets.only(top: 10.h, bottom: 12.h),
|
||
child: RichTextContent(
|
||
html: cleanRichText(analysis),
|
||
textStyle: TextStyle(
|
||
fontSize: 16.sp,
|
||
height: 1.55,
|
||
color: ExerciseColors.title,
|
||
),
|
||
),
|
||
));
|
||
}
|
||
|
||
for (final audio in audios) {
|
||
content.add(Padding(
|
||
padding: EdgeInsets.only(
|
||
top: hasBody ? 30.h : 10.h,
|
||
bottom: hasVideo ? 30.h : 0,
|
||
),
|
||
child: AudioPlayerCard(
|
||
url: '${audio['audioUrl'] ?? audio['url'] ?? ''}',
|
||
),
|
||
));
|
||
}
|
||
|
||
for (final video in videos) {
|
||
content.add(Padding(
|
||
padding: EdgeInsets.only(
|
||
top: (hasBody || hasAudio) ? 30.h : 10.h,
|
||
),
|
||
child: VideoPlayerView(
|
||
url: '${video['videoUrl'] ?? video['url'] ?? ''}',
|
||
),
|
||
));
|
||
}
|
||
|
||
children.add(Container(
|
||
margin: EdgeInsets.only(top: 43.h, bottom: 40.h),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(12.r),
|
||
// 对标 border-box 渐变 146.26deg
|
||
gradient: const LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: [
|
||
Color(0xFF95F8E7),
|
||
Color(0xFFB9DDFF),
|
||
Color(0xFF99DCFE),
|
||
],
|
||
stops: [0, 0.4952, 1],
|
||
),
|
||
),
|
||
padding: EdgeInsets.all(1.5.w),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(12.r - 1.5.w),
|
||
// 对标 padding-box 渐变 180deg
|
||
gradient: const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Color(0xFFE0F9FF), Colors.white, Colors.white],
|
||
stops: [0, 0.3, 1],
|
||
),
|
||
),
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Positioned.fill(
|
||
child: Padding(
|
||
padding: EdgeInsets.fromLTRB(14.w, 21.h, 14.w, 14.h),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: content,
|
||
),
|
||
),
|
||
),
|
||
// 对标 .analysis-left-bg
|
||
Positioned(
|
||
left: -0.5.w,
|
||
top: -18.h,
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/StaticResource/2026-04-16/analysis.png'),
|
||
width: 78.w,
|
||
placeholder: (_, _) => SizedBox(width: 78.w),
|
||
errorWidget: (_, _, _) => SizedBox(width: 78.w),
|
||
),
|
||
),
|
||
// 对标 .analysis-right-icon
|
||
Positioned(
|
||
right: -5.w,
|
||
top: 0,
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/StaticResource/2026-04-08/analysis-right-icon.png'),
|
||
width: 90.w,
|
||
placeholder: (_, _) => SizedBox(width: 90.w),
|
||
errorWidget: (_, _, _) => SizedBox(width: 90.w),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
));
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 面板外观:渐变背景 + 顶部圆角 + 白色描边 + 上方阴影
|
||
// (对标原 AnimatedContainer 装饰,尺寸必须与 child 一致,
|
||
// 故直接用 DecoratedBox + ClipRRect,不额外加 padding)
|
||
// ============================================================
|
||
class _PanelSheetDecoration extends SizedSheetDecoration {
|
||
const _PanelSheetDecoration() : super(size: SheetSize.stretch);
|
||
|
||
@override
|
||
Widget build(BuildContext context, Widget child) {
|
||
final radius = BorderRadius.vertical(top: Radius.circular(16.r));
|
||
return DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
// 对标 l-floating-panel:180deg #E7F2FB → #FAFDFF 10% → #FFFFFF
|
||
gradient: const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Color(0xFFE7F2FB), Color(0xFFFAFDFF), Colors.white],
|
||
stops: [0, 0.1, 1],
|
||
),
|
||
borderRadius: radius,
|
||
border: Border.all(color: Colors.white, width: 1.w),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: const Color(0x14000000),
|
||
offset: Offset(0, -10.h),
|
||
blurRadius: 20.h,
|
||
),
|
||
],
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: radius,
|
||
child: child,
|
||
),
|
||
);
|
||
}
|
||
}
|