Flutter 实战:tip_splitter 小费分摊器的账单计算、人数拆分与鸿蒙适配解析
Flutter 实战:tip_splitter 小费分摊器的账单计算、人数拆分与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
小费分摊器是一个很适合拆解 Flutter 表单交互的项目。它同时包含 金额输入、税费输入、百分比滑块、快捷按钮、自定义小费、人数增减 和 结果明细展示。这些能力组合在一起,刚好构成了一个完整的小型计算工具。
tip_splitter 的核心目标很明确:输入账单金额、税费、小费规则和分摊人数后,实时计算每个人需要支付的总额,并展示账单、小费、税费三项人均明细。项目代码集中在一个 Flutter 页面中,适合用来理解表单状态、实时计算和跨端适配。
金额类工具的关键不是公式有多复杂,而是每个输入变化后,结果能否稳定、及时、可解释地刷新。

图示说明:上图展示 Flutter 页面在移动端的布局组织方式。tip_splitter 的实际界面由账单输入区、小费选择区、人数控制区和结果卡片组成。
一、项目定位与功能边界
1.1 应用定位
tip_splitter 是一个账单小费分摊工具,适用于餐厅聚餐、多人付款、旅行账单和费用均摊等场景。它不依赖后端接口,所有计算都在 Dart 层完成。
项目当前支持:
- 输入账单金额。
- 输入可选税费。
- 通过滑块选择小费比例。
- 使用 10%、15%、18%、20% 快捷小费按钮。
- 输入自定义小费金额。
- 将总额按 1 到 20 人拆分。
- 展示每人总额、每人账单、每人税费和每人小费。
1.2 功能模块
| 功能模块 | 页面表现 | 源码实现 |
|---|---|---|
| 账单输入 | Bill Amount 输入框 |
_billController |
| 税费输入 | Tax (optional) 输入框 |
_taxController |
| 小费比例 | Slider |
_tipPercent |
| 快捷小费 | 10、15、18、20 四个按钮 | map 生成按钮 |
| 自定义小费 | Or enter custom tip 输入框 |
_customTipController |
| 人数拆分 | 加减按钮 | _peopleCount |
| 结果卡片 | Total Per Person 与明细 |
_totalPerPerson 等状态 |
1.3 技术栈
| 技术点 | 使用位置 | 价值 |
|---|---|---|
| Flutter | 页面、输入框、按钮、卡片 | 快速构建跨端 UI |
| Dart | 金额解析和公式计算 | 保证业务逻辑集中 |
| Material 3 | 主题与组件风格 | useMaterial3: true |
| StatefulWidget | 页面交互状态 | 输入、滑块、人数变化都要刷新 |
| TextEditingController | 管理文本输入 | 控制账单、税费、自定义小费 |
二、工程结构与运行环境
2.1 工程结构
tip_splitter 是标准 Flutter 工程,主逻辑位于 lib/main.dart。
| 文件或目录 | 作用 |
|---|---|
lib/main.dart |
应用入口、页面状态、计算逻辑和 UI |
pubspec.yaml |
Flutter SDK 和测试依赖声明 |
test/widget_test.dart |
Widget 测试入口 |
ohos/ |
鸿蒙平台工程目录 |
analysis_options.yaml |
Dart 静态分析规则 |
2.2 运行命令
flutter doctor
flutter pub get
flutter run
这三个命令分别用于检查开发环境、恢复依赖和启动项目。当前项目没有复杂三方插件,主要依赖 Flutter SDK 自身能力。
2.3 依赖声明
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
依赖结构很轻,说明核心逻辑都在 Dart 和 Flutter Widget 层。对鸿蒙适配来说,这种项目的重点通常不是原生插件,而是输入法、布局、字体和触控反馈。
三、应用入口与主题配置
3.1 main 函数
Flutter 应用从 main() 进入:
import 'package:flutter/material.dart';
void main() {
runApp(const TipSplitterApp());
}
入口函数只负责启动根组件,不包含业务状态。
3.2 根组件
class TipSplitterApp extends StatelessWidget {
const TipSplitterApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Tip Splitter',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const TipSplitterHomePage(title: 'Tip Splitter'),
);
}
}
根组件使用 StatelessWidget,负责配置标题、主题和首页。实际计算状态放在 TipSplitterHomePage 的 State 中。
3.3 主题色
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal)
青绿色主题会影响 AppBar、按钮选中态、结果卡片和强调文本,让整个工具有统一的视觉焦点。
四、StatefulWidget 与核心状态
4.1 首页组件
class TipSplitterHomePage extends StatefulWidget {
const TipSplitterHomePage({super.key, required this.title});
final String title;
State<TipSplitterHomePage> createState() => _TipSplitterHomePageState();
}
首页需要响应输入框、滑块、按钮和人数控制,因此使用 StatefulWidget。
4.2 输入控制器
final TextEditingController _billController =
TextEditingController(text: '100');
final TextEditingController _taxController =
TextEditingController(text: '0');
final TextEditingController _customTipController =
TextEditingController();
账单默认值为 100,税费默认值为 0。这样应用首次打开时就能立即展示一个可理解的默认结果。
4.3 计算状态
double _tipPercent = 15;
int _peopleCount = 2;
double _totalPerPerson = 0;
double _tipPerPerson = 0;
double _taxPerPerson = 0;
| 状态字段 | 含义 |
|---|---|
_tipPercent |
当前小费百分比 |
_peopleCount |
分摊人数 |
_totalPerPerson |
每人应付总额 |
_tipPerPerson |
每人小费 |
_taxPerPerson |
每人税费 |
五、初始化与生命周期
5.1 initState 初始化
void initState() {
super.initState();
_calculate();
}
页面创建后立即调用 _calculate(),让默认账单、默认税费、默认小费比例和默认人数生成初始结果。
5.2 dispose 释放资源
void dispose() {
_billController.dispose();
_taxController.dispose();
_customTipController.dispose();
super.dispose();
}
TextEditingController 持有文本状态和监听能力,页面销毁时需要释放。
5.3 生命周期路径
创建首页 State
初始化三个输入控制器
执行 initState
计算默认结果
用户修改账单、小费或人数
多次触发计算
页面销毁时释放控制器
这个路径完整体现了一个表单计算器页面的生命周期。
六、核心计算方法解析
6.1 输入解析
final bill = double.tryParse(_billController.text) ?? 0;
final tax = double.tryParse(_taxController.text) ?? 0;
final customTip = double.tryParse(_customTipController.text) ?? 0;
double.tryParse 可以避免空输入和非法输入导致异常。解析失败时按 0 参与计算。
6.2 小费优先级
double tip;
if (customTip > 0) {
tip = customTip;
} else {
tip = bill * _tipPercent / 100;
}
这里的业务规则很清楚:只要自定义小费大于 0,就优先使用自定义金额;否则使用百分比小费。
6.3 人均结果
final total = bill + tax + tip;
final perPerson = total / _peopleCount;
final tipPerPerson = tip / _peopleCount;
final taxPerPerson = tax / _peopleCount;
总额由账单、税费和小费相加,再按人数平均拆分。
6.4 写入状态
setState(() {
_totalPerPerson = perPerson;
_tipPerPerson = tipPerPerson;
_taxPerPerson = taxPerPerson;
});
写入结果后,Flutter 会重新构建页面,结果卡片随之刷新。
七、账单与税费输入框
7.1 账单金额输入
TextField(
controller: _billController,
keyboardType: TextInputType.number,
style: const TextStyle(fontSize: 24),
decoration: InputDecoration(
labelText: 'Bill Amount',
prefixText: '\$ ',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
onChanged: (_) => _calculate(),
)
账单金额是最主要的输入,因此字体更大,并带有美元前缀。
7.2 税费输入
TextField(
controller: _taxController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Tax (optional)',
prefixText: '\$ ',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
onChanged: (_) => _calculate(),
)
税费是可选项,默认值为 0。输入变化后同样触发实时计算。
7.3 输入行为表
| 输入项 | 默认值 | 前缀 | 变化后动作 |
|---|---|---|---|
| Bill Amount | 100 |
$ |
调用 _calculate() |
| Tax | 0 |
$ |
调用 _calculate() |
| Custom Tip | 空 | $ |
更新小费规则并计算 |
八、小费比例滑块
8.1 Slider 配置
Slider(
value: _tipPercent,
min: 0,
max: 30,
divisions: 30,
label: '${_tipPercent.toInt()}%',
onChanged: (value) {
setState(() {
_tipPercent = value;
_customTipController.clear();
});
_calculate();
},
)
滑块范围是 0 到 30,分成 30 段,因此每次移动对应 1%。
8.2 清空自定义小费
当用户操作滑块时,源码会清空自定义小费:
_customTipController.clear();
这能避免“百分比小费”和“自定义小费”同时生效造成理解混乱。
8.3 滑块交互价值
滑块适合处理连续数值,用户可以快速微调小费比例。例如从 15% 调整到 18%,比手动输入更直观。
| 滑块值 | 账单金额 | 小费金额 |
|---|---|---|
| 10% | 100 | 10 |
| 15% | 100 | 15 |
| 20% | 100 | 20 |
| 30% | 100 | 30 |
九、快捷小费按钮
9.1 按钮生成
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [10, 15, 18, 20].map((percent) {
return ElevatedButton(
onPressed: () {
setState(() {
_tipPercent = percent * 1.0;
_customTipController.clear();
});
_calculate();
},
child: Text('$percent%'),
);
}).toList(),
)
四个常用比例通过数组生成,避免重复写四个按钮。
9.2 选中态颜色
style: ElevatedButton.styleFrom(
backgroundColor:
_tipPercent == percent ? Colors.teal : Colors.grey.shade300,
)
当前比例对应的按钮会使用主题色,其他按钮使用灰色。
9.3 快捷比例设计
| 比例 | 常见含义 |
|---|---|
| 10% | 基础小费 |
| 15% | 默认小费 |
| 18% | 偏高小费 |
| 20% | 高满意度小费 |
这些比例覆盖了常见场景,也让用户不必每次拖动滑块。
十、自定义小费输入
10.1 自定义输入框
TextField(
controller: _customTipController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Or enter custom tip',
prefixText: '\$ ',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
onChanged: (value) {
if (value.isNotEmpty) {
setState(() {
_tipPercent = 0;
});
}
_calculate();
},
)
自定义小费表示直接输入小费金额,而不是输入百分比。
10.2 自定义小费优先级
只要自定义小费大于 0,计算方法就使用自定义金额:
final customTip = double.tryParse(_customTipController.text) ?? 0;
final tip = customTip > 0 ? customTip : bill * _tipPercent / 100;
这让用户可以处理更灵活的付款场景,比如直接给 12 美元小费。
10.3 与滑块的关系
| 操作 | 结果 |
|---|---|
| 拖动滑块 | 清空自定义小费 |
| 点击快捷按钮 | 清空自定义小费 |
| 输入自定义小费 | 小费比例置为 0 |
| 清空自定义小费 | 回到百分比规则 |
这套互斥关系让页面状态保持可解释。
十一、人数拆分控制
11.1 人数显示
Text(
'$_peopleCount',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
)
人数使用大字号显示,方便用户确认当前分摊人数。
11.2 减少人数
IconButton(
onPressed: _peopleCount > 1
? () {
setState(() => _peopleCount--);
_calculate();
}
: null,
icon: const Icon(Icons.remove_circle),
)
当人数为 1 时,减少按钮禁用,避免除以 0 或出现无效人数。
11.3 增加人数
IconButton(
onPressed: _peopleCount < 20
? () {
setState(() => _peopleCount++);
_calculate();
}
: null,
icon: const Icon(Icons.add_circle),
)
人数上限为 20,适合大多数聚餐分摊场景。
十二、结果卡片与明细拆解
12.1 总额卡片
Card(
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [Colors.teal.shade100, Colors.teal.shade50],
),
),
child: Column(
children: [
const Text('Total Per Person'),
Text('\$${_totalPerPerson.toStringAsFixed(2)}'),
],
),
),
)
结果卡片使用大字号展示每人应付总额,这是用户最关心的信息。
12.2 金额格式化
'\$${_totalPerPerson.toStringAsFixed(2)}'
金额统一保留两位小数,符合付款类工具的展示习惯。
12.3 明细拆解
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildBreakdownItem(
'Bill',
(_totalPerPerson - _tipPerPerson - _taxPerPerson),
),
_buildBreakdownItem('Tax', _taxPerPerson),
_buildBreakdownItem('Tip', _tipPerPerson),
],
)
明细区把每人总额拆成账单、税费和小费三部分,用户可以快速确认计算来源。
十三、明细组件封装
13.1 _buildBreakdownItem
Widget _buildBreakdownItem(String label, double value) {
return Column(
children: [
Text(label, style: TextStyle(color: Colors.grey.shade600)),
const SizedBox(height: 4),
Text(
'\$${value.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
);
}
这个方法避免重复写三段相同布局,让结果明细更容易维护。
13.2 明细计算
| 明细项 | 计算来源 |
|---|---|
| Bill | 每人总额减去每人小费和每人税费 |
| Tax | _taxPerPerson |
| Tip | _tipPerPerson |
13.3 封装价值
封装后,如果要统一调整明细字体、颜色或间距,只需要改 _buildBreakdownItem 一个方法。
十四、异常输入与边界场景
14.1 空输入
double.tryParse('') ?? 0
空输入会按 0 计算,页面不会崩溃。
14.2 人数边界
| 边界 | 处理方式 |
|---|---|
| 最少 1 人 | 减少按钮禁用 |
| 最多 20 人 | 增加按钮禁用 |
| 人数变化 | 重新计算全部结果 |
人数不允许小于 1,这是避免除法异常的关键。
14.3 自定义小费边界
自定义小费只有大于 0 时才生效。输入为空、0 或无法解析时,计算会回到百分比小费规则。
14.4 负数输入
当前源码没有显式禁止负数金额。如果面向真实支付场景,可以在输入层限制非负数,或者在计算前做金额校验。
十五、Widget 测试设计
15.1 基础渲染测试
import 'package:flutter_test/flutter_test.dart';
import '../lib/main.dart';
void main() {
testWidgets('tip splitter renders home page', (tester) async {
await tester.pumpWidget(const TipSplitterApp());
expect(find.text('Tip Splitter'), findsWidgets);
expect(find.text('Bill Amount'), findsOneWidget);
expect(find.text('Total Per Person'), findsOneWidget);
});
}
这个测试验证根组件、账单输入和结果区域是否正常出现。
15.2 默认结果测试
testWidgets('default bill calculates per person amount', (tester) async {
await tester.pumpWidget(const TipSplitterApp());
expect(find.text('\$57.50'), findsOneWidget);
});
默认账单 100,默认税费 0,默认小费 15%,默认人数 2,因此每人总额为 57.50。
15.3 人数变化测试
testWidgets('people count changes result', (tester) async {
await tester.pumpWidget(const TipSplitterApp());
await tester.tap(find.byIcon(Icons.add_circle));
await tester.pump();
expect(find.text('3'), findsOneWidget);
});
这个测试覆盖人数增加按钮和页面刷新。
15.4 测试命令
flutter test
保持测试里的根组件名称与源码一致,可以避免默认模板测试遗留造成编译失败。
十六、鸿蒙适配观察
16.1 适配优势
tip_splitter 的核心计算全部在 Dart 层完成,没有复杂平台通道或原生插件依赖,因此鸿蒙侧主要关注渲染和交互表现。
| 维度 | 当前项目情况 | 鸿蒙侧关注点 |
|---|---|---|
| 金额计算 | Dart 公式 | 多端结果一致 |
| 数字输入 | TextField |
小数点、键盘、焦点 |
| 滑块 | Slider |
拖动手势和刻度反馈 |
| 按钮 | ElevatedButton、IconButton |
点击区域和禁用态 |
| 结果卡片 | 大字号金额 | 小屏和字体缩放 |
16.2 构建命令参考
flutter clean
flutter pub get
flutter build hap
具体命令取决于所使用的鸿蒙 Flutter 适配环境。对这个项目而言,主要验证输入、滑动、点击和结果展示即可。
16.3 运行验证要点
- 应用可以正常启动到首页。
- 账单、税费和自定义小费输入稳定。
- 小费滑块在 0 到 30 之间正常拖动。
- 快捷比例按钮能切换并刷新结果。
- 人数增减按钮在 1 和 20 的边界正确禁用。
- 结果卡片金额和三项明细显示完整。
鸿蒙适配中,纯 Dart 计算通常较稳定,真正需要细看的往往是输入法、小数点、滑块手势、按钮状态和卡片布局。
十七、性能与可维护性
17.1 性能特征
tip_splitter 的计算量非常小,性能重点不在算法复杂度,而在状态更新是否清楚、输入变化是否稳定。
| 维度 | 当前表现 |
|---|---|
| 计算复杂度 | 常量级 |
| 输入控件数量 | 3 个 |
| 结果状态数量 | 3 个 |
| 分摊人数范围 | 1 到 20 |
| UI 更新方式 | 页面级 setState |
17.2 当前结构优点
- 输入控制器集中定义。
_calculate()统一处理账单、小费、税费和人数。- 小费比例和自定义小费有明确优先级。
- 人数边界通过按钮禁用控制。
- 结果明细使用方法封装,减少重复布局。
17.3 可演进方向
如果项目继续扩展,可以把计算逻辑抽离为纯函数。
double calculateTip({
required double bill,
required double percent,
required double customTip,
}) {
return customTip > 0 ? customTip : bill * percent / 100;
}
double calculatePerPerson({
required double bill,
required double tax,
required double tip,
required int people,
}) {
return (bill + tax + tip) / people;
}
抽离后可以直接做单元测试,Widget 测试只负责页面交互。
十八、常见问题与优化建议
18.1 为什么自定义小费优先于百分比
因为自定义小费表示用户已经输入了明确金额。如果仍然使用百分比,会让输入语义冲突。源码通过 customTip > 0 明确了优先级。
18.2 为什么人数最少是 1
总额拆分需要除以人数。人数为 0 会导致无效计算,因此源码在 _peopleCount > 1 时才允许减少。
18.3 为什么金额保留两位小数
付款场景通常按两位小数展示。toStringAsFixed(2) 可以让结果格式稳定,避免金额位数忽长忽短。
18.4 为什么滑块变化会清空自定义小费
滑块代表百分比小费,自定义输入代表固定小费。清空另一方可以让当前生效规则更清晰。
18.5 为什么默认账单是 100
默认账单让页面初始状态就能展示完整结果,用户不用输入也能理解这个工具的计算方式。
18.6 为什么适合做鸿蒙适配示例
项目依赖简单,业务逻辑集中在 Dart 层,同时又包含输入、滑块、按钮、卡片等常见交互控件,适合用来验证 Flutter 工具类页面在鸿蒙侧的表现。
总结
tip_splitter 用一个 Flutter 页面完成了账单小费分摊器的核心闭环:账单金额和税费通过输入框采集,小费可以通过滑块、快捷按钮或自定义金额确定,人数通过加减按钮控制,最终结果以人均总额和三项明细的形式展示。
从代码结构看,项目的亮点是状态集中、公式清晰、交互路径短。_calculate() 统一处理金额解析、小费优先级和人均拆分,页面只负责把用户输入映射到状态变化。
从鸿蒙适配角度看,这类项目没有复杂原生依赖,主要验证数字输入、滑块手势、按钮禁用态、金额格式和小屏布局即可。只要这些细节稳定,跨端运行体验就会比较可靠。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐



所有评论(0)