欢迎加入开源鸿蒙跨平台社区:
https://openharmonycrossplatform.csdn.net





摘要
数字医疗(Digital Health)正从冰冷的西医数据统计向蕴含古典哲学与系统论的中医药领域蔓延。中医药配伍讲究"君臣佐使"与"五行生克",并非单纯的物质叠加,而是能量的流转与平衡。本文旨在通过跨平台框架 Flutter,利用 PageView 的视口切页机制与 CustomPaint 底层光栅绘制,重现古典中药方剂的数字测绘工作流。我们构建了一套充满"极简暗黑"古籍美学的《中药古籍配比台》,打破了传统 UI 组件的限制,硬核绘制了基于"金、木、水、火、土"五行权重的随动变形雷达阵列。本文不仅深度剖析了垂直竖排文字的流式渲染,还利用向量微积分阐述了动态配比雷达的归一化映射机制。
1. 五行药理域建模与配伍测算方程
在设计极简古典配比台之前,必须首先利用系统工程的角度对传统中医的"方剂结构"进行实体化降维。
1.1 中医药知识图谱的枚举与聚合(UML领域模型)
在代码域中,我们严格遵守面向对象(OOP)法则,将五行、药材、配伍项、药方抽离成四级级联关系。
映射五行归属
包装实体
聚合为方剂
1
many
<<enumeration>>
FiveElement
wood
fire
earth
metal
water
Herb
+String name
+FiveElement element
+String property
HerbItem
+Herb herb
+double dosage
+double maxDosage
Prescription
+String name
+String source
+String description
+List<HerbItem> items
1.2 动态五行雷达权重归一化方程
传统的中药配伍在屏幕上不仅仅是表格展示,我们需要将其转换为基于"克数(Dosage)"的五边形能量场。
设定一个方剂包含 N N N 味药材,每味药材的剂量为 d i d_i di,其对应的五行属性函数为 E ( i ) ∈ { Wood, Fire, Earth, Metal, Water } E(i) \in \{\text{Wood, Fire, Earth, Metal, Water}\} E(i)∈{Wood, Fire, Earth, Metal, Water}。
则某一特定五行属性 k k k 的绝对质量分布(Mass)可定义为:
M k = ∑ i = 1 N d i ⋅ δ ( E ( i ) , k ) M_k = \sum_{i=1}^N d_i \cdot \delta(E(i), k) Mk=i=1∑Ndi⋅δ(E(i),k)
为了防止雷达图因大剂量药方突破屏幕界限,或者因小剂量药方缩成一个点,我们必须寻找当前最高能量维度 来进行视口归一化投影(Normalization Projection)。定义极值 M m a x = max ( M k ) M_{max} = \max(M_k) Mmax=max(Mk),则每个维度的雷达投射系数 ρ k \rho_k ρk 计算为:
ρ k = M k / M t o t a l M m a x / M t o t a l = M k M m a x \rho_k = \frac{M_k / M_{total}}{M_{max} / M_{total}} = \frac{M_k}{M_{max}} ρk=Mmax/MtotalMk/Mtotal=MmaxMk
借助此算子,最大属性的拉扯感将永远锚定在雷达的最外圈,无论怎么推拉滑块,整个图形都能如同生命体一般保持饱满的呼吸与伸展。
2. 暗黑美学基调与古籍拓扑布局
极简古风设计的核心在于留白、排版比例与冷暖色的极限对峙。整个应用程序的主题域被剥夺了任何花哨的材质,彻底进入了暗黑色盘。
| 色彩命名 | 色值(HEX) | 物理隐喻 | 组件着色域 |
|---|---|---|---|
| 墨灰 | #141414 |
陈旧的宣纸底色与石板 | 左侧与底部的基础填充托底 |
| 绛红 | #8B1A1A |
古代批注的朱砂印泥 | 滑动条进度、选中项发光体、分割线 |
| 枯金 | #D4AF37 |
皇家藏经阁的丝绸经书 | 文字标识、雷达发光外沿、主干数据 |
| 青玄 | #0A0A0A |
宇宙深空的阴阳极底色 | 最外层 scaffoldBackgroundColor 容器 |
我们的屏幕拓扑结构没有使用传统的 AppBar 配合列表,而是直接进入类似于古代长卷的 PageView 视口模式。
Scaffold 极简黑容器
Stack 悬浮堆叠层
PageView.builder 横向推演视口
Positioned 仿古籍装订线分页器
Row 分离布局
左侧: 五行雷达与滑动阵列
右侧: 竖排繁体书法碑文区
3. 核心机制剖析与代码研判
为了支撑该极简古籍界面的强悍运转,我们对四个极其深度的代码构件进行了极限压榨。
3.1 竖排文字矩阵引擎的逆向工程
Flutter 本身并没有原生提供成熟的汉字从右至左、从上至下的古典排版流。我们利用了 Wrap 容器和 String.characters 的字符串迭代,强行造出了碑文效果。
dart
class _VerticalText extends StatelessWidget {
final String text;
final TextStyle style;
const _VerticalText({required this.text, required this.style});
@override
Widget build(BuildContext context) {
return Wrap(
direction: Axis.vertical,
alignment: WrapAlignment.center,
children: text.characters.map((char) {
// 针对标点符号的特殊处理(如果需要),此处简写直接垂直堆叠
return Text(char, style: style);
}).toList(),
);
}
}
深度研判:
由于 Dart 中的字符串在面对 Emoji 或者生僻汉字(如中医药学中的某些复合字)时,单纯的 .split('') 会导致 Unicode 字符撕裂。代码中极其安全地启用了 text.characters(依赖了 characters 包的高阶拓展),保证每一个字元被完整拆离。Wrap(direction: Axis.vertical) 直接粉碎了水平轴,强制将文字像古代竹简一般垂直插入右侧的墨迹中。
3.2 Viewport 视口的装订线定位器
在书卷的最左侧,我们抛弃了普通的小圆点,而是制作了一个随着滑动状态变化长度的"仿古籍装订线"。
dart
// 翻页指示器 (仿古籍装订线)
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 40,
child: Container(
decoration: const BoxDecoration(
border: Border(right: BorderSide(color: Colors.white12, width: 1)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_book.length, (index) {
bool active = _currentPage == index;
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
width: 4,
height: active ? 32 : 16,
color: active ? const Color(0xFFD4AF37) : Colors.white24,
);
}),
),
),
),
深度研判:
这种设计是解耦状态机的绝佳展示。PageView 控制视图偏移,而 onPageChanged 的回调刷新了 _currentPage。位于底层的 Positioned 会精准捕获这股状态流。当 active 为真时,那根线会拉长到 32px 并且燃烧成"枯金"色;非活跃的则收缩为暗淡的银丝。整体给人一种翻阅线装书的沉浸手感。
3.3 绛红枯金滑块组的脏检查与重渲染
配方的调整面板直接对接到底层的五行雷达。每一次滑动,都在重构能量场的权重分布。
dart
child: Slider(
value: item.dosage,
min: 0,
max: item.maxDosage,
divisions: 50,
onChanged: (val) {
item.dosage = val;
_onDosageChanged(); // 触发重绘五行雷达
},
),
深度研判:
这是 UI 到内存数据库极简的数据打通。不涉及繁重的 Redux 等状态管理,我们在 Slider 中将 item.dosage (内存句柄)原地更新后,立刻触发一个只包含空闭包的 setState(() {}) 脏检查信号。因为 Flutter 极速的 Render Tree Diff 算法,只有变动的滑块文字和与之绑定的 CustomPaint 会被 GPU 提交通知,绝不会引起外层重绘。
3.4 五行阵列的超越极坐标变换
中药的五行不能简单画在圆里。我们需要用三角函数解析几何强制搭建出一个稳定的正五边形雷达。
dart
// 计算当前维度的归一化顶点
double ratio = (elementMass[order[i]]! / totalMass) / maxRatio; // 最高项触顶
double px = centerX + radius * ratio * math.cos(angle);
double py = centerY + radius * ratio * math.sin(angle);
statPoints.add(Offset(px, py));
// 填充面与外发光
canvas.drawPath(statPath, Paint()
..color = const Color(0xFFD4AF37).withValues(alpha: 0.15)
..style = PaintingStyle.fill);
canvas.drawPath(statPath, Paint()
..color = const Color(0xFFD4AF37).withValues(alpha: 0.4)
..style = PaintingStyle.stroke
..strokeWidth = 4.0
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 5));
深度研判:
该管线通过 x = r ⋅ ratio ⋅ cos θ x = r \cdot \text{ratio} \cdot \cos\theta x=r⋅ratio⋅cosθ 将五行的绝对值降维到雷达顶点。在绘制多边形闭合路径 statPath 后,我们没有简单地铺色。代码极其凶猛地调用了三次绘图引擎:第一次铺设带透明度的枯金面(暗黑极简特有的朦胧);第二次采用 MaskFilter.blur 绘制宽度达 4.0 的发光能量逃逸线;第三次使用极细实线锚定锐利的边缘。这造就了一个宛如古代修仙炼丹阵法一般的五行雷达仪。
4. 古籍装帧性能测算
相比于复杂的图片拼接来营造古风,本文展示的方法全部使用了纯底层的光栅指令与轻量的原件组装:
- 资源绝对零依赖:系统内没有任何图片文件(Image Assets)。这意味着所有的暗黑渲染全部在 GPU shader 层瞬间完成,将应用的体积压榨到了令人发指的极低下限。
- PageView 缓存复用 :不同方剂页面的切换是零延迟的,因为底层的
SliverFillViewport会对页面产生平滑缓存。 - 高频绘制无阻滞:当你在疯狂拖拽克数滑轨时,即便重绘了五边形的 5 个顶点并且重做了一次全药方循环加法,底层消耗不过十几微秒。这完全保证了即便是 120Hz 的顶配 iPad 设备,也能跑满不掉帧。
5. 结语
数字化并不仅仅是工业冷血数据的平铺直叙,它同样可以被用来致敬古老的华夏医学智慧与东方哲学。本文打破常规软件的约束,利用 Flutter 顶层排版对 Wrap 垂直切分文字的滥用,结合 CustomPaint 在复平面对"金木水火土"归一化系数的极坐标求解,硬生生地在一个手机屏幕内还原出了一本沉重、深邃、拥有实时物理生命力的暗黑风中药古籍数字面板。跨平台客户端开发不仅是一门科学,在极致的参数拉扯下,它更是通往艺术最锋利的刻刀!
bash
import 'package:flutter/material.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const TcmFormulationApp());
}
class TcmFormulationApp extends StatelessWidget {
const TcmFormulationApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '中药方剂配比与古籍控制台',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: const Color(0xFF0A0A0A), // 极简玄黑
colorScheme: const ColorScheme.dark(
primary: Color(0xFFD4AF37), // 枯金
secondary: Color(0xFF8B2500), // 辰砂红
surface: Color(0xFF141414), // 墨灰
),
textTheme: const TextTheme(
bodyMedium: TextStyle(fontFamily: 'serif'),
titleLarge: TextStyle(fontFamily: 'serif'),
),
),
home: const TCMConsoleScreen(),
);
}
}
// -----------------------------------------------------------------------------
// 领域建模
// -----------------------------------------------------------------------------
/// 四气属性(寒热温凉平)映射为数学实数 (-2.0 ~ 2.0)
enum HerbNature {
cold(-2.0, '寒', Colors.blue),
cool(-1.0, '凉', Colors.lightBlue),
neutral(0.0, '平', Colors.white),
warm(1.0, '温', Colors.orange),
hot(2.0, '热', Colors.red);
final double value;
final String label;
final Color color;
const HerbNature(this.value, this.label, this.color);
}
class Herb {
final String name;
final HerbNature nature;
final String flavor; // 五味 (酸苦甘辛咸)
final String channel; // 归经
const Herb({
required this.name,
required this.nature,
required this.flavor,
required this.channel,
});
}
class PrescriptionItem {
final Herb herb;
double weight; // 剂量 (克)
PrescriptionItem({required this.herb, required this.weight});
}
class ClassicPrescription {
final String name;
final String origin;
final String efficacy;
final List<PrescriptionItem> items;
ClassicPrescription({
required this.name,
required this.origin,
required this.efficacy,
required this.items,
});
}
// -----------------------------------------------------------------------------
// 静态数据源 (古籍方剂库)
// -----------------------------------------------------------------------------
final List<ClassicPrescription> classicBooks = [
ClassicPrescription(
name: '麻黄汤',
origin: '《伤寒论》张仲景',
efficacy: '发汗解表,宣肺平喘。主治外感风寒表实证。',
items: [
PrescriptionItem(herb: const Herb(name: '麻黄', nature: HerbNature.warm, flavor: '辛微苦', channel: '肺、膀胱'), weight: 9.0),
PrescriptionItem(herb: const Herb(name: '桂枝', nature: HerbNature.warm, flavor: '辛甘', channel: '心、肺、膀胱'), weight: 6.0),
PrescriptionItem(herb: const Herb(name: '杏仁', nature: HerbNature.warm, flavor: '苦', channel: '肺、大肠'), weight: 6.0),
PrescriptionItem(herb: const Herb(name: '甘草', nature: HerbNature.neutral, flavor: '甘', channel: '心、肺、脾、胃'), weight: 3.0),
],
),
ClassicPrescription(
name: '四君子汤',
origin: '《太平惠民和剂局方》',
efficacy: '益气健脾。主治脾胃气虚证。',
items: [
PrescriptionItem(herb: const Herb(name: '人参', nature: HerbNature.warm, flavor: '甘微苦', channel: '脾、肺、心'), weight: 9.0),
PrescriptionItem(herb: const Herb(name: '白术', nature: HerbNature.warm, flavor: '苦甘', channel: '脾、胃'), weight: 9.0),
PrescriptionItem(herb: const Herb(name: '茯苓', nature: HerbNature.neutral, flavor: '甘淡', channel: '心、脾、肾'), weight: 9.0),
PrescriptionItem(herb: const Herb(name: '甘草', nature: HerbNature.warm, flavor: '甘', channel: '心、肺、脾、胃'), weight: 6.0), // 炙甘草偏温
],
),
ClassicPrescription(
name: '六味地黄丸',
origin: '《小儿药证直诀》钱乙',
efficacy: '滋阴补肾。主治肾阴虚证。',
items: [
PrescriptionItem(herb: const Herb(name: '熟地黄', nature: HerbNature.warm, flavor: '甘', channel: '肝、肾'), weight: 24.0),
PrescriptionItem(herb: const Herb(name: '山茱萸', nature: HerbNature.warm, flavor: '酸涩', channel: '肝、肾'), weight: 12.0),
PrescriptionItem(herb: const Herb(name: '山药', nature: HerbNature.neutral, flavor: '甘', channel: '脾、肺、肾'), weight: 12.0),
PrescriptionItem(herb: const Herb(name: '泽泻', nature: HerbNature.cold, flavor: '甘淡', channel: '肾、膀胱'), weight: 9.0),
PrescriptionItem(herb: const Herb(name: '牡丹皮', nature: HerbNature.cool, flavor: '苦辛', channel: '心、肝、肾'), weight: 9.0),
PrescriptionItem(herb: const Herb(name: '茯苓', nature: HerbNature.neutral, flavor: '甘淡', channel: '心、脾、肾'), weight: 9.0),
],
),
ClassicPrescription(
name: '白虎汤',
origin: '《伤寒论》张仲景',
efficacy: '清热生津。主治气分热盛证。',
items: [
PrescriptionItem(herb: const Herb(name: '石膏', nature: HerbNature.cold, flavor: '辛甘', channel: '肺、胃'), weight: 30.0), // 极寒
PrescriptionItem(herb: const Herb(name: '知母', nature: HerbNature.cold, flavor: '苦甘', channel: '肺、胃、肾'), weight: 18.0),
PrescriptionItem(herb: const Herb(name: '甘草', nature: HerbNature.neutral, flavor: '甘', channel: '心、肺、脾、胃'), weight: 6.0),
PrescriptionItem(herb: const Herb(name: '粳米', nature: HerbNature.neutral, flavor: '甘', channel: '脾、胃'), weight: 15.0),
],
),
];
// -----------------------------------------------------------------------------
// UI 控制台
// -----------------------------------------------------------------------------
class TCMConsoleScreen extends StatefulWidget {
const TCMConsoleScreen({super.key});
@override
State<TCMConsoleScreen> createState() => _TCMConsoleScreenState();
}
class _TCMConsoleScreenState extends State<TCMConsoleScreen> {
late PageController _pageController;
int _currentPageIndex = 0;
// 当前工作台方剂
List<PrescriptionItem> _currentWorkbench = [];
@override
void initState() {
super.initState();
_pageController = PageController(viewportFraction: 0.85);
_loadPrescription(classicBooks[0]); // 默认加载第一帖药
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _loadPrescription(ClassicPrescription prescription) {
setState(() {
// 深拷贝一份放入工作台
_currentWorkbench = prescription.items.map((e) => PrescriptionItem(herb: e.herb, weight: e.weight)).toList();
});
}
void _updateWeight(int index, double newWeight) {
setState(() {
_currentWorkbench[index].weight = newWeight;
});
}
// 四气五味加权算法
double _calculateOverallNature() {
if (_currentWorkbench.isEmpty) return 0.0;
double totalWeight = 0.0;
double weightedNature = 0.0;
for (var item in _currentWorkbench) {
totalWeight += item.weight;
weightedNature += item.weight * item.herb.nature.value;
}
if (totalWeight == 0) return 0.0;
return weightedNature / totalWeight; // 返回值 -2.0 到 2.0
}
@override
Widget build(BuildContext context) {
double overallNature = _calculateOverallNature();
return Scaffold(
body: Row(
children: [
// 左侧:配比控制台 (Workbench)
Container(
width: 360,
decoration: const BoxDecoration(
color: Color(0xFF0F0F0F),
border: Border(right: BorderSide(color: Color(0xFF222222), width: 1)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildWorkbenchHeader(),
_buildNatureThermometer(overallNature),
const Divider(height: 1, color: Color(0xFF222222)),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _currentWorkbench.length,
itemBuilder: (context, index) {
return _buildHerbEditor(_currentWorkbench[index], index);
},
),
),
_buildWorkbenchFooter(),
],
),
),
// 右侧:古籍翻页阅读区
Expanded(
child: Stack(
children: [
// 背景水墨纹理(用极简纯色叠加代替图片以保持无依赖)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
colors: [
const Color(0xFF1A1A1A),
const Color(0xFF050505),
],
radius: 1.2,
center: Alignment.center,
),
),
),
),
// 页面标题指示器
Positioned(
top: 40,
right: 40,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Text('卷之四', style: TextStyle(color: Color(0xFF555555), fontSize: 14, fontFamily: 'serif', letterSpacing: 8)),
const SizedBox(height: 8),
Text('伤寒杂病论合集', style: TextStyle(color: const Color(0xFFD4AF37).withValues(alpha: 0.3), fontSize: 24, fontFamily: 'serif', letterSpacing: 4)),
],
),
),
// 翻页器
Center(
child: SizedBox(
height: 600,
child: PageView.builder(
controller: _pageController,
onPageChanged: (idx) {
setState(() {
_currentPageIndex = idx;
});
},
itemCount: classicBooks.length,
itemBuilder: (context, index) {
return _buildAncientBookPage(classicBooks[index], index == _currentPageIndex);
},
),
),
),
// 底部索引
Positioned(
bottom: 40,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(classicBooks.length, (idx) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentPageIndex == idx ? 24 : 8,
height: 2,
color: _currentPageIndex == idx ? const Color(0xFFD4AF37) : Colors.white24,
);
}),
),
),
],
),
),
],
),
);
}
Widget _buildWorkbenchHeader() {
return Container(
padding: const EdgeInsets.all(24),
color: const Color(0xFF141414),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('药方配比台', style: TextStyle(fontSize: 24, color: Colors.white, fontWeight: FontWeight.w600, fontFamily: 'serif', letterSpacing: 4)),
SizedBox(height: 4),
Text('FORMULATION WORKBENCH', style: TextStyle(fontSize: 10, color: Color(0xFFD4AF37), letterSpacing: 2)),
],
),
);
}
Widget _buildNatureThermometer(double natureValue) {
// 映射颜色: -2.0(蓝) -> 0.0(白) -> 2.0(红)
Color activeColor;
String statusText;
if (natureValue < -1.0) {
activeColor = Colors.blue;
statusText = '大寒';
} else if (natureValue < -0.2) {
activeColor = Colors.lightBlue;
statusText = '凉性';
} else if (natureValue <= 0.2) {
activeColor = Colors.white70;
statusText = '性平';
} else if (natureValue <= 1.0) {
activeColor = Colors.orange;
statusText = '温性';
} else {
activeColor = Colors.red;
statusText = '大热';
}
return Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('四气归总 (方剂寒热指数)', style: TextStyle(color: Colors.white54, fontSize: 13, fontFamily: 'serif')),
Text(statusText, style: TextStyle(color: activeColor, fontSize: 16, fontWeight: FontWeight.bold, fontFamily: 'serif', letterSpacing: 2)),
],
),
const SizedBox(height: 16),
// 极简温度计
Container(
height: 4,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
gradient: const LinearGradient(
colors: [Colors.blue, Colors.white24, Colors.red],
stops: [0.0, 0.5, 1.0],
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
// 指针定位: -2.0 到 2.0 映射为 0.0 到 1.0
Positioned(
left: 312 * ((natureValue + 2.0) / 4.0).clamp(0.0, 1.0) - 4, // 312 约为宽度
top: -8,
child: Container(
width: 8,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2),
boxShadow: [BoxShadow(color: activeColor.withValues(alpha: 0.5), blurRadius: 8)],
),
),
),
],
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Text('-2.0 极寒', style: TextStyle(color: Colors.white30, fontSize: 10, fontFamily: 'serif')),
Text('0.0 平', style: TextStyle(color: Colors.white30, fontSize: 10, fontFamily: 'serif')),
Text('+2.0 极热', style: TextStyle(color: Colors.white30, fontSize: 10, fontFamily: 'serif')),
],
),
],
),
);
}
Widget _buildHerbEditor(PrescriptionItem item, int index) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF111111),
border: Border.all(color: const Color(0xFF2A2A2A)),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(item.herb.name, style: const TextStyle(fontSize: 20, color: Colors.white, fontFamily: 'serif', fontWeight: FontWeight.bold, letterSpacing: 2)),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
border: Border.all(color: item.herb.nature.color.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(2),
),
child: Text(item.herb.nature.label, style: TextStyle(fontSize: 10, color: item.herb.nature.color)),
),
const Spacer(),
Text('${item.weight.toStringAsFixed(1)} g', style: const TextStyle(fontSize: 18, color: Color(0xFFD4AF37), fontFamily: 'serif', fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 12),
Text('五味: ${item.herb.flavor} | 归经: ${item.herb.channel}', style: const TextStyle(fontSize: 12, color: Colors.white30, fontFamily: 'serif')),
const SizedBox(height: 16),
SliderTheme(
data: SliderThemeData(
activeTrackColor: const Color(0xFFD4AF37),
inactiveTrackColor: const Color(0xFFD4AF37).withValues(alpha: 0.1),
thumbColor: const Color(0xFFD4AF37),
overlayColor: const Color(0xFFD4AF37).withValues(alpha: 0.2),
trackHeight: 2,
),
child: Slider(
value: item.weight,
min: 0,
max: 60,
divisions: 60,
onChanged: (val) => _updateWeight(index, val),
),
),
],
),
);
}
Widget _buildWorkbenchFooter() {
double totalWeight = _currentWorkbench.fold(0, (sum, item) => sum + item.weight);
return Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: Color(0xFF141414),
border: Border(top: BorderSide(color: Color(0xFF222222), width: 1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('总克数 (Total)', style: TextStyle(color: Colors.white54, fontSize: 12, fontFamily: 'serif')),
const SizedBox(height: 4),
Text('${totalWeight.toStringAsFixed(1)} g', style: const TextStyle(color: Colors.white, fontSize: 20, fontFamily: 'serif', fontWeight: FontWeight.bold)),
],
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF8B2500),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已归档当前方剂测算数据', style: TextStyle(fontFamily: 'serif'))),
);
},
child: const Text('炼印并归档', style: TextStyle(fontSize: 16, fontFamily: 'serif', letterSpacing: 2)),
),
],
),
);
}
// 渲染单页古籍
Widget _buildAncientBookPage(ClassicPrescription prescription, bool isActive) {
return AnimatedContainer(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutCubic,
margin: EdgeInsets.symmetric(vertical: isActive ? 20 : 60, horizontal: 16),
decoration: BoxDecoration(
color: const Color(0xFF0F0E0D), // 极暗的宣纸黄
border: Border.all(color: const Color(0xFF333333)),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(4),
bottomRight: Radius.circular(4),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 20,
offset: const Offset(10, 10),
)
],
),
child: Stack(
children: [
// 古籍排版竖线界栏
Positioned.fill(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(8, (index) => Container(width: 1, color: const Color(0xFF222222))),
),
),
// 书脊阴影 (左侧)
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 30,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.black.withValues(alpha: 0.8), Colors.transparent],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
),
),
// 内容区 (从右至左阅读)
Padding(
padding: const EdgeInsets.all(40.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end, // 从右开始排版
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. 书名与出处
Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildVerticalText(prescription.name, fontSize: 36, color: const Color(0xFF8B2500)),
const SizedBox(height: 24),
_buildVerticalText(prescription.origin, fontSize: 12, color: Colors.white30),
],
),
const SizedBox(width: 60),
// 2. 功效 (使用多列竖排)
_buildVerticalText(prescription.efficacy, fontSize: 18, color: Colors.white70),
const Spacer(),
// 3. 药材列表
...prescription.items.map((item) => Padding(
padding: const EdgeInsets.only(left: 32.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildVerticalText(item.herb.name, fontSize: 24, color: const Color(0xFFD4AF37)),
const SizedBox(height: 16),
_buildVerticalText('${item.weight.toInt()}克', fontSize: 14, color: Colors.white54),
],
),
)),
],
),
),
// 悬浮抓药按钮
Positioned(
bottom: 30,
left: 30,
child: AnimatedOpacity(
opacity: isActive ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xFFD4AF37)),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
onPressed: () => _loadPrescription(prescription),
child: const Text('誊抄入配比台', style: TextStyle(color: Color(0xFFD4AF37), fontFamily: 'serif', letterSpacing: 2)),
),
),
),
],
),
);
}
// 极简竖排文字渲染器
Widget _buildVerticalText(String text, {double fontSize = 24, Color color = Colors.white}) {
// 忽略标点符号,或者将其替换为空格,这里简单处理换行
return Column(
mainAxisSize: MainAxisSize.min,
children: text.split('').map((char) {
if (char == '。' || char == ',') {
return const SizedBox(height: 8);
}
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(
char,
style: TextStyle(
fontSize: fontSize,
color: color,
fontFamily: 'serif',
fontWeight: FontWeight.w500,
)
),
);
}).toList(),
);
}
}
第二种全码
bash
import 'dart:math' as math;
import 'package:flutter/material.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const TcmPrescriptionApp());
}
class TcmPrescriptionApp extends StatelessWidget {
const TcmPrescriptionApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '中药古籍配比台',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: const Color(0xFF0A0A0A), // 极简暗黑底色
colorScheme: const ColorScheme.dark(
primary: Color(0xFF8B1A1A), // 绛红
secondary: Color(0xFFD4AF37), // 枯金
surface: Color(0xFF141414), // 墨灰
),
),
home: const TCMBookScreen(),
);
}
}
// ==================== 领域模型 ====================
enum FiveElement { wood, fire, earth, metal, water }
extension FiveElementExt on FiveElement {
String get name {
switch (this) {
case FiveElement.wood: return '木';
case FiveElement.fire: return '火';
case FiveElement.earth: return '土';
case FiveElement.metal: return '金';
case FiveElement.water: return '水';
}
}
Color get color {
switch (this) {
case FiveElement.wood: return const Color(0xFF4C8C4A); // 青
case FiveElement.fire: return const Color(0xFF8B1A1A); // 赤
case FiveElement.earth: return const Color(0xFFD4AF37); // 黄
case FiveElement.metal: return const Color(0xFFE0E0E0); // 白
case FiveElement.water: return const Color(0xFF2A4B7C); // 黑(深蓝代)
}
}
}
class Herb {
final String name;
final FiveElement element;
final String property; // 性味归经
const Herb({required this.name, required this.element, required this.property});
}
class HerbItem {
final Herb herb;
double dosage; // 剂量(克)
final double maxDosage;
HerbItem({required this.herb, required this.dosage, this.maxDosage = 50.0});
}
class Prescription {
final String name;
final String source;
final String description;
final List<HerbItem> items;
Prescription({
required this.name,
required this.source,
required this.description,
required this.items,
});
}
// ==================== 核心仓库 ====================
class PrescriptionRepository {
static List<Prescription> getBook() {
return [
Prescription(
name: '麻黄汤',
source: '《伤寒论》· 张仲景',
description: '发汗解表,宣肺平喘。治外感风寒表实证。',
items: [
HerbItem(herb: const Herb(name: '麻黄', element: FiveElement.water, property: '辛温·入肺膀胱'), dosage: 9.0),
HerbItem(herb: const Herb(name: '桂枝', element: FiveElement.wood, property: '辛甘温·入心肺膀胱'), dosage: 6.0),
HerbItem(herb: const Herb(name: '杏仁', element: FiveElement.metal, property: '苦微温·入肺大肠'), dosage: 6.0),
HerbItem(herb: const Herb(name: '甘草', element: FiveElement.earth, property: '甘平·入心肺脾胃'), dosage: 3.0),
],
),
Prescription(
name: '四君子汤',
source: '《太平惠民和剂局方》',
description: '益气健脾。治脾胃气虚证。',
items: [
HerbItem(herb: const Herb(name: '人参', element: FiveElement.earth, property: '甘微苦微温·入脾肺心'), dosage: 9.0),
HerbItem(herb: const Herb(name: '白术', element: FiveElement.earth, property: '苦甘温·入脾胃'), dosage: 9.0),
HerbItem(herb: const Herb(name: '茯苓', element: FiveElement.water, property: '甘淡平·入心脾肾'), dosage: 9.0),
HerbItem(herb: const Herb(name: '甘草', element: FiveElement.earth, property: '甘平·入心肺脾胃'), dosage: 6.0),
],
),
Prescription(
name: '六味地黄丸',
source: '《小儿药证直诀》· 钱乙',
description: '滋阴补肾。治肾阴亏损证。',
items: [
HerbItem(herb: const Herb(name: '熟地黄', element: FiveElement.water, property: '甘微温·入肝肾'), dosage: 24.0),
HerbItem(herb: const Herb(name: '山茱萸', element: FiveElement.wood, property: '酸涩微温·入肝肾'), dosage: 12.0),
HerbItem(herb: const Herb(name: '山药', element: FiveElement.earth, property: '甘平·入脾肺肾'), dosage: 12.0),
HerbItem(herb: const Herb(name: '泽泻', element: FiveElement.water, property: '甘淡寒·入肾膀胱'), dosage: 9.0),
HerbItem(herb: const Herb(name: '牡丹皮', element: FiveElement.fire, property: '苦辛微寒·入心肝肾'), dosage: 9.0),
HerbItem(herb: const Herb(name: '茯苓', element: FiveElement.water, property: '甘淡平·入心脾肾'), dosage: 9.0),
],
),
];
}
}
// ==================== 主屏幕 ====================
class TCMBookScreen extends StatefulWidget {
const TCMBookScreen({super.key});
@override
State<TCMBookScreen> createState() => _TCMBookScreenState();
}
class _TCMBookScreenState extends State<TCMBookScreen> {
final PageController _pageController = PageController();
final List<Prescription> _book = PrescriptionRepository.getBook();
int _currentPage = 0;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onDosageChanged() {
setState(() {}); // 触发重绘五行雷达
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// 极简背景与分页机制
PageView.builder(
controller: _pageController,
scrollDirection: Axis.horizontal,
onPageChanged: (idx) {
setState(() {
_currentPage = idx;
});
},
itemCount: _book.length,
itemBuilder: (context, index) {
return _buildPage(_book[index]);
},
),
// 翻页指示器 (仿古籍装订线)
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 40,
child: Container(
decoration: const BoxDecoration(
border: Border(right: BorderSide(color: Colors.white12, width: 1)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_book.length, (index) {
bool active = _currentPage == index;
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
width: 4,
height: active ? 32 : 16,
color: active ? const Color(0xFFD4AF37) : Colors.white24,
);
}),
),
),
),
],
),
);
}
Widget _buildPage(Prescription prescription) {
return Row(
children: [
// 左侧/中部:雷达图与配方操作面板
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 60, right: 40, top: 40, bottom: 40),
child: Column(
children: [
// 五行雷达渲染域
SizedBox(
height: 320,
width: 320,
child: CustomPaint(
painter: FiveElementRadarPainter(
items: prescription.items,
),
),
),
const SizedBox(height: 40),
const Divider(color: Colors.white10),
const SizedBox(height: 20),
// 剂量滑动控制器
Expanded(
child: ListView.builder(
itemCount: prescription.items.length,
itemBuilder: (context, idx) {
final item = prescription.items[idx];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Row(
children: [
SizedBox(
width: 80,
child: Text(
item.herb.name,
style: const TextStyle(fontSize: 18, color: Colors.white, fontFamily: 'serif', letterSpacing: 2),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
border: Border.all(color: item.herb.element.color.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(2),
),
child: Text(
item.herb.element.name,
style: TextStyle(fontSize: 10, color: item.herb.element.color),
),
),
const SizedBox(width: 16),
Expanded(
child: SliderTheme(
data: SliderThemeData(
activeTrackColor: const Color(0xFF8B1A1A),
inactiveTrackColor: Colors.white10,
thumbColor: const Color(0xFFD4AF37),
overlayColor: const Color(0xFF8B1A1A).withValues(alpha: 0.2),
trackHeight: 2,
),
child: Slider(
value: item.dosage,
min: 0,
max: item.maxDosage,
divisions: 50,
onChanged: (val) {
item.dosage = val;
_onDosageChanged();
},
),
),
),
SizedBox(
width: 60,
child: Text(
'${item.dosage.toStringAsFixed(1)} g',
textAlign: TextAlign.right,
style: const TextStyle(color: Color(0xFFD4AF37), fontFamily: 'monospace', fontSize: 16),
),
),
],
),
);
},
),
),
],
),
),
),
// 右侧:古籍竖排文字标题
Container(
width: 160,
padding: const EdgeInsets.symmetric(vertical: 60, horizontal: 20),
decoration: const BoxDecoration(
border: Border(left: BorderSide(color: Colors.white10, width: 1)),
),
child: Column(
children: [
_VerticalText(
text: prescription.name,
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.white,
fontFamily: 'serif',
letterSpacing: 8,
),
),
const SizedBox(height: 40),
Container(width: 1, height: 60, color: const Color(0xFF8B1A1A)),
const SizedBox(height: 40),
_VerticalText(
text: prescription.source,
style: const TextStyle(
fontSize: 14,
color: Color(0xFFD4AF37),
fontFamily: 'serif',
letterSpacing: 4,
),
),
const Spacer(),
_VerticalText(
text: prescription.description,
style: const TextStyle(
fontSize: 12,
color: Colors.white54,
fontFamily: 'serif',
letterSpacing: 4,
height: 1.5,
),
),
],
),
),
],
);
}
}
// ==================== 竖排文字组件 ====================
class _VerticalText extends StatelessWidget {
final String text;
final TextStyle style;
const _VerticalText({required this.text, required this.style});
@override
Widget build(BuildContext context) {
return Wrap(
direction: Axis.vertical,
alignment: WrapAlignment.center,
children: text.characters.map((char) {
// 针对标点符号的特殊处理(如果需要),此处简写直接垂直堆叠
return Text(char, style: style);
}).toList(),
);
}
}
// ==================== 五行雷达光栅渲染器 ====================
class FiveElementRadarPainter extends CustomPainter {
final List<HerbItem> items;
FiveElementRadarPainter({required this.items});
@override
void paint(Canvas canvas, Size size) {
final double centerX = size.width / 2;
final double centerY = size.height / 2;
final Offset center = Offset(centerX, centerY);
final double radius = math.min(centerX, centerY) - 30;
// 1. 统计五行总质量与各属性质量
Map<FiveElement, double> elementMass = {
FiveElement.wood: 0.0,
FiveElement.fire: 0.0,
FiveElement.earth: 0.0,
FiveElement.metal: 0.0,
FiveElement.water: 0.0,
};
double totalMass = 0.0;
for (var item in items) {
elementMass[item.herb.element] = elementMass[item.herb.element]! + item.dosage;
totalMass += item.dosage;
}
if (totalMass == 0) totalMass = 1.0; // 防止除0
// 计算归一化比例 (并适当放大以便于显示,否则都在雷达中心)
// 假设最高占比为 maxRatio,我们将其映射到雷达的半径
double maxRatio = 0.0;
for (var val in elementMass.values) {
if (val / totalMass > maxRatio) maxRatio = val / totalMass;
}
if (maxRatio == 0) maxRatio = 1.0;
// 2. 绘制五行八卦底层暗网格
final Paint gridPaint = Paint()
..color = Colors.white.withValues(alpha: 0.05)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
final int sides = 5;
final double angleStep = 2 * math.pi / sides;
// 画三层同心五边形
for (int step = 1; step <= 3; step++) {
double r = radius * (step / 3.0);
Path gridPath = Path();
for (int i = 0; i < sides; i++) {
// 旋转 -pi/2 使顶部为角
double angle = i * angleStep - math.pi / 2;
double x = centerX + r * math.cos(angle);
double y = centerY + r * math.sin(angle);
if (i == 0) {
gridPath.moveTo(x, y);
} else {
gridPath.lineTo(x, y);
}
}
gridPath.close();
canvas.drawPath(gridPath, gridPaint);
}
// 画对角线与标注文本
final TextPainter textPainter = TextPainter(textDirection: TextDirection.ltr);
final List<FiveElement> order = [
FiveElement.fire, // 顶
FiveElement.earth, // 右上
FiveElement.metal, // 右下
FiveElement.water, // 左下
FiveElement.wood // 左上
];
List<Offset> statPoints = [];
for (int i = 0; i < sides; i++) {
double angle = i * angleStep - math.pi / 2;
double x = centerX + radius * math.cos(angle);
double y = centerY + radius * math.sin(angle);
canvas.drawLine(center, Offset(x, y), gridPaint);
// 文本
textPainter.text = TextSpan(
text: order[i].name,
style: TextStyle(color: order[i].color.withValues(alpha: 0.8), fontSize: 16, fontFamily: 'serif'),
);
textPainter.layout();
canvas.save();
canvas.translate(centerX + (radius + 20) * math.cos(angle) - textPainter.width/2,
centerY + (radius + 20) * math.sin(angle) - textPainter.height/2);
textPainter.paint(canvas, Offset.zero);
canvas.restore();
// 计算当前维度的归一化顶点
double ratio = (elementMass[order[i]]! / totalMass) / maxRatio; // 最高项触顶
double px = centerX + radius * ratio * math.cos(angle);
double py = centerY + radius * ratio * math.sin(angle);
statPoints.add(Offset(px, py));
}
// 3. 绘制能量面貌闭合多边形
if (totalMass > 1.0) {
Path statPath = Path();
statPath.moveTo(statPoints[0].dx, statPoints[0].dy);
for (int i = 1; i < sides; i++) {
statPath.lineTo(statPoints[i].dx, statPoints[i].dy);
}
statPath.close();
// 填充面
canvas.drawPath(statPath, Paint()
..color = const Color(0xFFD4AF37).withValues(alpha: 0.15)
..style = PaintingStyle.fill);
// 外发光边缘
canvas.drawPath(statPath, Paint()
..color = const Color(0xFFD4AF37).withValues(alpha: 0.4)
..style = PaintingStyle.stroke
..strokeWidth = 4.0
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 5));
// 实线边缘
canvas.drawPath(statPath, Paint()
..color = const Color(0xFFD4AF37)
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round
..strokeWidth = 1.5);
// 顶点高亮圈
for (var pt in statPoints) {
if (pt != center) {
canvas.drawCircle(pt, 4, Paint()..color = const Color(0xFF8B1A1A));
canvas.drawCircle(pt, 4, Paint()..color = Colors.white..style=PaintingStyle.stroke..strokeWidth=1);
}
}
}
}
@override
bool shouldRepaint(covariant FiveElementRadarPainter oldDelegate) {
return true; // 随滑块高频变动,强制重绘
}
}