Flutter三方库适配OpenHarmony【tip_calculator】小费计算器项目完整实战
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
tip_calculator 是一个基于 Flutter 实现的 Tip Calculator 小费计算器 。项目核心源码位于 lib/main.dart,同时保留了 ohos OpenHarmony 平台工程目录,适合用来学习 Flutter 表单输入 、实时计算 、Slider 滑块联动 、快捷百分比按钮 、分摊人数边界控制 和 OpenHarmony 平台承载。
小费计算器是一个典型的轻量财务工具。它不需要网络、数据库或复杂状态管理,但需要把输入、滑块、按钮、人数、金额公式和结果卡片组织成稳定的数据流。相比只点击一次按钮的计算器,它更强调 输入监听 和 状态联动。
本文重点回答三个问题:
tip_calculator如何用TextEditingController.addListener监听账单金额变化。- Flutter 如何通过
Slider、快捷按钮和加减按钮驱动小费比例与分摊人数变化。 - 这个项目适配 OpenHarmony 时,如何验证输入、滑动、按钮边界和金额展示。

图示说明:本文聚焦 Flutter 小费计算器的输入联动、金额计算和 OpenHarmony 工程承载,核心源码位于 lib/main.dart。
一、背景与目标
1.1 项目功能概览
tip_calculator 是一个轻量账单小费计算工具。用户输入账单金额后,可以通过滑块或快捷按钮调整小费百分比,并使用加减按钮调整分摊人数。页面会实时计算小费金额、总金额和人均金额。
当前项目真实支持的功能包括:
- 输入账单金额。
- 使用
$前缀提示金额单位。 - 默认账单金额为
0。 - 默认小费比例为
15%。 - 使用
Slider在0%到30%之间调整小费比例。 Slider使用 30 个分段,步进为 1%。- 提供 10%、15%、18%、20%、25% 快捷比例按钮。
- 使用加减按钮调整分摊人数。
- 分摊人数最小为 1,最大为 20。
- 实时计算小费金额、总金额和人均金额。
- 使用
toStringAsFixed(2)将金额保留两位小数。 - 使用橙色渐变卡片展示计算结果。
1.2 技术关键词
| 关键词 | 在项目中的作用 |
|---|---|
TextEditingController |
管理账单输入 |
addListener |
监听输入变化并触发计算 |
double.tryParse |
安全解析账单金额 |
Slider |
调整小费百分比 |
divisions: 30 |
将滑块拆成 30 个百分比刻度 |
ElevatedButton |
快捷设置常见小费比例 |
IconButton |
调整分摊人数 |
toStringAsFixed(2) |
金额保留两位小数 |
LinearGradient |
构建橙色渐变结果卡片 |
SingleChildScrollView |
适配小屏幕和输入法弹出 |
1.3 项目目标
这个项目的目标不是实现复杂记账系统,而是把账单小费计算的核心链路做完整:
- 用户输入账单金额。
- 用户设置小费百分比。
- 用户设置分摊人数。
- 应用计算小费金额。
- 应用计算总金额。
- 应用计算人均金额。
- 页面实时展示结果。
关键点:小费计算器的难点不在公式,而在多个输入源共同驱动同一组结果状态。输入框、滑块、快捷按钮和人数按钮都要汇入同一个
_calculate()。
二、环境准备
2.1 项目目录结构
当前项目采用 Flutter 标准目录,同时包含 OpenHarmony 平台工程。
bash
tip_calculator/
├── lib/
│ └── main.dart
├── ohos/
│ ├── AppScope/
│ └── entry/
├── test/
│ └── widget_test.dart
├── pubspec.yaml
├── analysis_options.yaml
└── README.md
2.2 核心文件说明
| 文件或目录 | 作用 | 本文重点 |
|---|---|---|
lib/main.dart |
应用入口、输入、状态、计算逻辑和结果 UI | 核心源码 |
pubspec.yaml |
项目名称、版本、依赖和 Flutter 配置 | 环境依赖 |
analysis_options.yaml |
Dart 静态分析规则 | 代码规范 |
test/ |
Flutter 测试目录 | 金额计算和页面验证 |
ohos/ |
OpenHarmony 平台工程 | 平台承载 |
2.3 pubspec 基础配置
项目名称是 tip_calculator,版本是 1.0.0+1,Dart SDK 约束是 ^3.9.2。
yaml
name: tip_calculator
description: "A new Flutter project."
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.9.2
publish_to: 'none' 表示这是应用工程,不会被误发布到 pub.dev。
2.4 项目依赖
当前项目只使用 Flutter SDK 和基础图标库。
yaml
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
小费计算不需要网络、数据库或复杂状态管理库,因此重点在输入监听、状态联动和 UI 展示。
2.5 常用命令
bash
flutter pub get
flutter analyze
flutter test
flutter run
| 命令 | 作用 |
|---|---|
flutter pub get |
获取项目依赖 |
flutter analyze |
执行静态分析 |
flutter test |
运行测试 |
flutter run |
启动调试运行 |
flutter build hap |
构建 OpenHarmony HAP 包 |
三、应用入口与主题配置
3.1 main 函数
Flutter 应用从 main() 启动。
dart
import 'package:flutter/material.dart';
void main() {
runApp(const TipCalculatorApp());
}
runApp(const TipCalculatorApp()) 将 TipCalculatorApp 放入 Flutter 渲染树根节点。
3.2 TipCalculatorApp
dart
class TipCalculatorApp extends StatelessWidget {
const TipCalculatorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Tip Calculator',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
useMaterial3: true,
),
home: const TipCalculatorHomePage(title: 'Tip Calculator'),
);
}
}
这里有三个关键信息:
- 应用标题是
Tip Calculator。 - 主题种子色使用
Colors.orange。 - 首页是
TipCalculatorHomePage,输入和计算状态从首页 State 中维护。
3.3 Material 3 主题
dart
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
useMaterial3: true,
),
useMaterial3: true 会影响 AppBar、TextField、Slider、ElevatedButton、Card 等组件的默认风格。当前项目用橙色贯穿滑块、按钮选中态和结果卡片。
四、页面状态设计
4.1 TipCalculatorHomePage
dart
class TipCalculatorHomePage extends StatefulWidget {
const TipCalculatorHomePage({super.key, required this.title});
final String title;
@override
State<TipCalculatorHomePage> createState() => _TipCalculatorHomePageState();
}
账单金额、小费比例、分摊人数和计算结果都会变化,因此页面使用 StatefulWidget。
4.2 核心状态字段
dart
class _TipCalculatorHomePageState extends State<TipCalculatorHomePage> {
final TextEditingController _billController = TextEditingController(text: '0');
double _tipPercent = 15;
int _splitCount = 1;
double _totalBill = 0;
double _tipAmount = 0;
double _perPerson = 0;
}
| 字段 | 类型 | 作用 |
|---|---|---|
_billController |
TextEditingController |
管理账单金额输入 |
_tipPercent |
double |
小费百分比,默认 15 |
_splitCount |
int |
分摊人数,默认 1 |
_totalBill |
double |
账单加小费后的总金额 |
_tipAmount |
double |
小费金额 |
_perPerson |
double |
人均金额 |
4.3 状态联动关系
#mermaid-svg-IT6BKjlRP3Seng9A{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IT6BKjlRP3Seng9A .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IT6BKjlRP3Seng9A .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IT6BKjlRP3Seng9A .error-icon{fill:#552222;}#mermaid-svg-IT6BKjlRP3Seng9A .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IT6BKjlRP3Seng9A .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IT6BKjlRP3Seng9A .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IT6BKjlRP3Seng9A .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IT6BKjlRP3Seng9A .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IT6BKjlRP3Seng9A .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IT6BKjlRP3Seng9A .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IT6BKjlRP3Seng9A .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IT6BKjlRP3Seng9A .marker.cross{stroke:#333333;}#mermaid-svg-IT6BKjlRP3Seng9A svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IT6BKjlRP3Seng9A p{margin:0;}#mermaid-svg-IT6BKjlRP3Seng9A .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IT6BKjlRP3Seng9A .cluster-label text{fill:#333;}#mermaid-svg-IT6BKjlRP3Seng9A .cluster-label span{color:#333;}#mermaid-svg-IT6BKjlRP3Seng9A .cluster-label span p{background-color:transparent;}#mermaid-svg-IT6BKjlRP3Seng9A .label text,#mermaid-svg-IT6BKjlRP3Seng9A span{fill:#333;color:#333;}#mermaid-svg-IT6BKjlRP3Seng9A .node rect,#mermaid-svg-IT6BKjlRP3Seng9A .node circle,#mermaid-svg-IT6BKjlRP3Seng9A .node ellipse,#mermaid-svg-IT6BKjlRP3Seng9A .node polygon,#mermaid-svg-IT6BKjlRP3Seng9A .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IT6BKjlRP3Seng9A .rough-node .label text,#mermaid-svg-IT6BKjlRP3Seng9A .node .label text,#mermaid-svg-IT6BKjlRP3Seng9A .image-shape .label,#mermaid-svg-IT6BKjlRP3Seng9A .icon-shape .label{text-anchor:middle;}#mermaid-svg-IT6BKjlRP3Seng9A .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IT6BKjlRP3Seng9A .rough-node .label,#mermaid-svg-IT6BKjlRP3Seng9A .node .label,#mermaid-svg-IT6BKjlRP3Seng9A .image-shape .label,#mermaid-svg-IT6BKjlRP3Seng9A .icon-shape .label{text-align:center;}#mermaid-svg-IT6BKjlRP3Seng9A .node.clickable{cursor:pointer;}#mermaid-svg-IT6BKjlRP3Seng9A .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IT6BKjlRP3Seng9A .arrowheadPath{fill:#333333;}#mermaid-svg-IT6BKjlRP3Seng9A .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IT6BKjlRP3Seng9A .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IT6BKjlRP3Seng9A .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IT6BKjlRP3Seng9A .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IT6BKjlRP3Seng9A .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IT6BKjlRP3Seng9A .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IT6BKjlRP3Seng9A .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IT6BKjlRP3Seng9A .cluster text{fill:#333;}#mermaid-svg-IT6BKjlRP3Seng9A .cluster span{color:#333;}#mermaid-svg-IT6BKjlRP3Seng9A div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IT6BKjlRP3Seng9A .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IT6BKjlRP3Seng9A rect.text{fill:none;stroke-width:0;}#mermaid-svg-IT6BKjlRP3Seng9A .icon-shape,#mermaid-svg-IT6BKjlRP3Seng9A .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IT6BKjlRP3Seng9A .icon-shape p,#mermaid-svg-IT6BKjlRP3Seng9A .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IT6BKjlRP3Seng9A .icon-shape .label rect,#mermaid-svg-IT6BKjlRP3Seng9A .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IT6BKjlRP3Seng9A .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IT6BKjlRP3Seng9A .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IT6BKjlRP3Seng9A :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 账单输入
_calculate
小费比例
分摊人数
_tipAmount
_totalBill
_perPerson
Tip Amount
Total Amount
Per Person
所有交互最终都会调用 _calculate(),这是当前项目保持结果一致的关键。
五、输入监听与实时计算
5.1 initState 注册监听
dart
@override
void initState() {
super.initState();
_billController.addListener(_calculate);
}
页面初始化时为 _billController 注册监听。只要账单输入发生变化,就会自动调用 _calculate()。
5.2 addListener 的价值
如果没有监听器,用户修改账单金额后需要额外点击按钮才能刷新结果。当前项目通过 addListener 实现实时计算,输入变化后结果卡片马上更新。
5.3 监听链路
#mermaid-svg-oxhBfIXcenvXf1J0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oxhBfIXcenvXf1J0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oxhBfIXcenvXf1J0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oxhBfIXcenvXf1J0 .error-icon{fill:#552222;}#mermaid-svg-oxhBfIXcenvXf1J0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oxhBfIXcenvXf1J0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oxhBfIXcenvXf1J0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oxhBfIXcenvXf1J0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oxhBfIXcenvXf1J0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oxhBfIXcenvXf1J0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oxhBfIXcenvXf1J0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oxhBfIXcenvXf1J0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oxhBfIXcenvXf1J0 .marker.cross{stroke:#333333;}#mermaid-svg-oxhBfIXcenvXf1J0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oxhBfIXcenvXf1J0 p{margin:0;}#mermaid-svg-oxhBfIXcenvXf1J0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oxhBfIXcenvXf1J0 .cluster-label text{fill:#333;}#mermaid-svg-oxhBfIXcenvXf1J0 .cluster-label span{color:#333;}#mermaid-svg-oxhBfIXcenvXf1J0 .cluster-label span p{background-color:transparent;}#mermaid-svg-oxhBfIXcenvXf1J0 .label text,#mermaid-svg-oxhBfIXcenvXf1J0 span{fill:#333;color:#333;}#mermaid-svg-oxhBfIXcenvXf1J0 .node rect,#mermaid-svg-oxhBfIXcenvXf1J0 .node circle,#mermaid-svg-oxhBfIXcenvXf1J0 .node ellipse,#mermaid-svg-oxhBfIXcenvXf1J0 .node polygon,#mermaid-svg-oxhBfIXcenvXf1J0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oxhBfIXcenvXf1J0 .rough-node .label text,#mermaid-svg-oxhBfIXcenvXf1J0 .node .label text,#mermaid-svg-oxhBfIXcenvXf1J0 .image-shape .label,#mermaid-svg-oxhBfIXcenvXf1J0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-oxhBfIXcenvXf1J0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oxhBfIXcenvXf1J0 .rough-node .label,#mermaid-svg-oxhBfIXcenvXf1J0 .node .label,#mermaid-svg-oxhBfIXcenvXf1J0 .image-shape .label,#mermaid-svg-oxhBfIXcenvXf1J0 .icon-shape .label{text-align:center;}#mermaid-svg-oxhBfIXcenvXf1J0 .node.clickable{cursor:pointer;}#mermaid-svg-oxhBfIXcenvXf1J0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oxhBfIXcenvXf1J0 .arrowheadPath{fill:#333333;}#mermaid-svg-oxhBfIXcenvXf1J0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oxhBfIXcenvXf1J0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oxhBfIXcenvXf1J0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oxhBfIXcenvXf1J0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oxhBfIXcenvXf1J0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oxhBfIXcenvXf1J0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oxhBfIXcenvXf1J0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oxhBfIXcenvXf1J0 .cluster text{fill:#333;}#mermaid-svg-oxhBfIXcenvXf1J0 .cluster span{color:#333;}#mermaid-svg-oxhBfIXcenvXf1J0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oxhBfIXcenvXf1J0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oxhBfIXcenvXf1J0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-oxhBfIXcenvXf1J0 .icon-shape,#mermaid-svg-oxhBfIXcenvXf1J0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oxhBfIXcenvXf1J0 .icon-shape p,#mermaid-svg-oxhBfIXcenvXf1J0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oxhBfIXcenvXf1J0 .icon-shape .label rect,#mermaid-svg-oxhBfIXcenvXf1J0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oxhBfIXcenvXf1J0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oxhBfIXcenvXf1J0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oxhBfIXcenvXf1J0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户输入账单金额
_billController.text 变化
addListener 触发
_calculate()
更新小费/总额/人均
setState 重建 UI
关键点:输入监听让小费计算器从"点击计算"变成"实时反馈"。这是这类小工具体验顺滑的核心。
六、金额计算逻辑
6.1 _calculate 方法
dart
void _calculate() {
final bill = double.tryParse(_billController.text) ?? 0;
setState(() {
_tipAmount = bill * _tipPercent / 100;
_totalBill = bill + _tipAmount;
_perPerson = _totalBill / _splitCount;
});
}
这个方法完成账单解析、小费金额、总金额和人均金额计算。
6.2 输入解析
dart
final bill = double.tryParse(_billController.text) ?? 0;
double.tryParse 解析失败时返回 null,再通过 ?? 0 兜底为 0。这样非法输入不会导致应用崩溃。
6.3 计算公式
| 结果 | 公式 |
|---|---|
| 小费金额 | bill * _tipPercent / 100 |
| 总金额 | bill + _tipAmount |
| 人均金额 | _totalBill / _splitCount |
6.4 计算示例
text
bill = 100
tipPercent = 15
splitCount = 2
tipAmount = 100 * 15 / 100 = 15
totalBill = 100 + 15 = 115
perPerson = 115 / 2 = 57.5
页面会显示:
text
Tip Amount: $15.00
Total Amount: $115.00
Per Person: $57.50
七、账单输入框实现
7.1 TextField 源码
dart
TextField(
controller: _billController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Bill Amount',
prefixText: '\$ ',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
style: const TextStyle(fontSize: 24),
)
账单输入框使用数字键盘,并在输入前显示 $ 前缀。
7.2 输入框配置表
| 属性 | 当前值 | 作用 |
|---|---|---|
controller |
_billController |
管理输入文本 |
keyboardType |
TextInputType.number |
弹出数字键盘 |
labelText |
Bill Amount |
输入项名称 |
prefixText |
$ |
展示金额单位 |
borderRadius |
12 |
圆角输入框 |
fontSize |
24 |
提升金额输入可读性 |
7.3 默认值
dart
final TextEditingController _billController = TextEditingController(text: '0');
默认账单金额为 0。页面初始结果也会基于 0 显示。
八、Slider 小费比例控制
8.1 小费比例标题
dart
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Tip Percentage', style: TextStyle(fontSize: 18)),
Text(
'${_tipPercent.toInt()}%',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
)
左侧显示标题,右侧实时显示当前小费百分比。
8.2 Slider 源码
dart
Slider(
value: _tipPercent,
min: 0,
max: 30,
divisions: 30,
label: '${_tipPercent.toInt()}%',
onChanged: (value) {
setState(() {
_tipPercent = value;
});
_calculate();
},
)
滑块范围是 0 到 30,分为 30 段,因此每次调整的粒度是 1%。
8.3 Slider 配置表
| 属性 | 当前值 | 作用 |
|---|---|---|
value |
_tipPercent |
当前百分比 |
min |
0 |
最低 0% |
max |
30 |
最高 30% |
divisions |
30 |
1% 步进 |
label |
${_tipPercent.toInt()}% |
拖动提示 |
onChanged |
更新并计算 | 联动结果 |
九、快捷百分比按钮
9.1 快捷按钮源码
dart
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [10, 15, 18, 20, 25].map((percent) {
return ElevatedButton(
onPressed: () {
setState(() {
_tipPercent = percent.toDouble();
});
_calculate();
},
style: ElevatedButton.styleFrom(
backgroundColor: _tipPercent == percent ? Colors.orange : Colors.grey.shade300,
),
child: Text('$percent%'),
);
}).toList(),
)
这里用列表 [10, 15, 18, 20, 25] 生成按钮,减少重复代码。
9.2 快捷比例表
| 按钮 | 设置值 |
|---|---|
10% |
10 |
15% |
15 |
18% |
18 |
20% |
20 |
25% |
25 |
9.3 选中态颜色
dart
backgroundColor: _tipPercent == percent
? Colors.orange
: Colors.grey.shade300
当前百分比与按钮百分比一致时,按钮显示橙色;否则显示灰色。
十、分摊人数控制
10.1 分摊行结构
dart
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Split Bill', style: TextStyle(fontSize: 18)),
Row(
children: [
IconButton(...),
Text('$_splitCount'),
IconButton(...),
],
),
],
)
左侧展示 Split Bill,右侧是减号、人数、加号。
10.2 减少人数
dart
IconButton(
onPressed: _splitCount > 1
? () {
setState(() {
_splitCount--;
});
_calculate();
}
: null,
icon: const Icon(Icons.remove_circle),
)
当 _splitCount 大于 1 时,减号可用;等于 1 时,按钮禁用。
10.3 增加人数
dart
IconButton(
onPressed: _splitCount < 20
? () {
setState(() {
_splitCount++;
});
_calculate();
}
: null,
icon: const Icon(Icons.add_circle),
)
当 _splitCount 小于 20 时,加号可用;达到 20 后,按钮禁用。
10.4 边界表
| 状态 | 减号 | 加号 |
|---|---|---|
_splitCount == 1 |
禁用 | 可用 |
1 < _splitCount < 20 |
可用 | 可用 |
_splitCount == 20 |
可用 | 禁用 |
十一、结果卡片实现
11.1 Card 容器
dart
Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Container(...),
)
结果区域使用 Card,通过阴影和圆角突出汇总结果。
11.2 渐变背景
dart
gradient: LinearGradient(
colors: [Colors.orange.shade300, Colors.orange.shade100],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
结果卡片使用橙色渐变,与主题色一致。
11.3 三个结果行
dart
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Tip Amount'),
Text('\$${_tipAmount.toStringAsFixed(2)}'),
],
)
结果卡片包含三行:
| 行 | 数据 |
|---|---|
Tip Amount |
_tipAmount |
Total Amount |
_totalBill |
Per Person |
_perPerson |
11.4 金额格式化
dart
'\$${_perPerson.toStringAsFixed(2)}'
金额统一保留两位小数,符合货币展示习惯。
十二、OpenHarmony 适配边界
12.1 Flutter 层职责
当前项目的业务逻辑全部在 Flutter 层完成。
text
Flutter 层:
TipCalculatorApp
TipCalculatorHomePage
_TipCalculatorHomePageState
TextEditingController
Slider
ElevatedButton
IconButton
_calculate
Card
LinearGradient
OpenHarmony 层:
AppScope
entry
EntryAbility
Index
resources
Flutter 层负责输入、滑块、按钮、计算和结果展示;OpenHarmony 层负责应用启动、模块配置和页面承载。
12.2 平台侧文件作用
| 层级 | 文件 | 作用 |
|---|---|---|
| 应用级 | AppScope/app.json5 |
描述应用整体信息 |
| 模块级 | entry/src/main/module.json5 |
描述模块入口和页面 |
| Ability | EntryAbility.ets |
应用启动入口 |
| 页面 | Index.ets |
承载 Flutter 页面 |
| 资源 | resources/base |
图标、字符串和配置资源 |
| 插件 | GeneratedPluginRegistrant.ets |
插件注册入口 |
12.3 小费计算器适配特点
当前项目不需要网络、定位、相机、传感器、数据库或文件读写权限。OpenHarmony 侧主要验证:
- 账单输入框是否能正常唤起数字键盘。
- Slider 是否能顺畅拖动。
- 快捷百分比按钮是否能正确选中。
- 分摊人数边界是否正确禁用按钮。
- 结果卡片金额是否实时更新。
- 小屏幕和输入法弹出时页面是否能滚动。
十三、测试与验证
13.1 页面验证路径
小费计算器的核心验证路径如下:
- 启动应用,标题显示
Tip Calculator。 - 账单输入框默认显示
0。 - 默认小费比例显示
15%。 - 默认分摊人数显示
1。 - 输入账单金额
100。 - 结果卡片显示小费
$15.00。 - 总金额显示
$115.00。 - 人均金额显示
$115.00。 - 将人数增加到 2,人均金额变为
$57.50。 - 点击
20%快捷按钮,结果重新计算。
13.2 Widget 测试示例
下面的测试验证页面基础内容。
dart
testWidgets('shows tip calculator page content', (WidgetTester tester) async {
await tester.pumpWidget(const TipCalculatorApp());
expect(find.text('Tip Calculator'), findsOneWidget);
expect(find.text('Bill Amount'), findsOneWidget);
expect(find.text('Tip Percentage'), findsOneWidget);
expect(find.text('Split Bill'), findsOneWidget);
});
13.3 金额计算测试
可以将小费计算抽成纯函数后测试。
dart
({double tip, double total, double perPerson}) calculateTip(
double bill,
double tipPercent,
int splitCount,
) {
final tip = bill * tipPercent / 100;
final total = bill + tip;
final perPerson = total / splitCount;
return (tip: tip, total: total, perPerson: perPerson);
}
测试用例:
dart
test('calculates tip values', () {
final result = calculateTip(100, 15, 2);
expect(result.tip.toStringAsFixed(2), '15.00');
expect(result.total.toStringAsFixed(2), '115.00');
expect(result.perPerson.toStringAsFixed(2), '57.50');
});
13.4 分摊边界测试
dart
bool canDecrease(int splitCount) => splitCount > 1;
bool canIncrease(int splitCount) => splitCount < 20;
测试用例:
dart
test('validates split count boundaries', () {
expect(canDecrease(1), isFalse);
expect(canDecrease(2), isTrue);
expect(canIncrease(19), isTrue);
expect(canIncrease(20), isFalse);
});
十四、常见问题与优化建议
14.1 为什么使用 addListener
账单输入变化后需要实时刷新结果。addListener 可以把输入框变化直接接入 _calculate(),减少额外按钮操作。
14.2 为什么使用 double.tryParse
用户输入本质是字符串。double.tryParse 能安全处理空字符串或非法字符,解析失败时当前项目兜底为 0。
14.3 为什么分摊人数最小是 1
人均金额公式是 _totalBill / _splitCount。如果分摊人数为 0,会出现除零问题。因此最小值必须是 1。
14.4 为什么 Slider 上限是 30
当前项目将小费比例限制在 0% 到 30%,覆盖常见小费场景,同时避免滑块范围过大导致控制不精确。
14.5 为什么金额保留两位小数
金额展示通常保留两位小数。当前项目使用 toStringAsFixed(2),让结果显示为 $15.00 这类标准格式。
14.6 当前代码的生命周期优化点
当前 _billController 注册了监听,但源码没有覆写 dispose() 释放 Controller。正式项目中应在页面销毁时释放控制器。
dart
@override
void dispose() {
_billController.dispose();
super.dispose();
}
14.7 UI 层可以如何继续优化
- 增加账单输入为空或非法时的错误提示。
- 支持小数键盘,方便输入
128.50。 - 将小费公式抽成纯函数,降低测试成本。
- 将结果行封装为独立 Widget。
- 增加本地化货币符号。
- 允许用户自定义快捷小费比例。
十五、完整业务链路复盘
15.1 输入链路
- 用户编辑账单输入框。
_billController.text发生变化。addListener触发_calculate()。double.tryParse解析账单金额。- 解析失败时账单金额按 0 处理。
15.2 小费比例链路
- 用户拖动 Slider。
_tipPercent更新为滑块值。- 页面显示新的百分比。
- 调用
_calculate()。 - 结果卡片金额更新。
15.3 分摊人数链路
- 用户点击加号或减号。
_splitCount在 1 到 20 之间变化。- 调用
_calculate()。 _perPerson根据新人数重新计算。- 结果卡片显示新的人均金额。
15.4 数据到 UI 的映射
| 数据 | 处理方式 | UI 输出 |
|---|---|---|
_billController.text |
double.tryParse |
账单金额 |
_tipPercent |
百分比公式 | 小费金额 |
_splitCount |
总金额除人数 | 人均金额 |
_tipAmount |
toStringAsFixed(2) |
Tip Amount |
_totalBill |
toStringAsFixed(2) |
Total Amount |
_perPerson |
toStringAsFixed(2) |
Per Person |
十六、核心源码总览
下面集中展示当前项目最关键的实时计算代码。
dart
class _TipCalculatorHomePageState extends State<TipCalculatorHomePage> {
final TextEditingController _billController = TextEditingController(text: '0');
double _tipPercent = 15;
int _splitCount = 1;
double _totalBill = 0;
double _tipAmount = 0;
double _perPerson = 0;
@override
void initState() {
super.initState();
_billController.addListener(_calculate);
}
void _calculate() {
final bill = double.tryParse(_billController.text) ?? 0;
setState(() {
_tipAmount = bill * _tipPercent / 100;
_totalBill = bill + _tipAmount;
_perPerson = _totalBill / _splitCount;
});
}
}
Slider 更新小费比例的代码如下:
dart
Slider(
value: _tipPercent,
min: 0,
max: 30,
divisions: 30,
label: '${_tipPercent.toInt()}%',
onChanged: (value) {
setState(() {
_tipPercent = value;
});
_calculate();
},
)
分摊人数加号逻辑如下:
dart
IconButton(
onPressed: _splitCount < 20
? () {
setState(() {
_splitCount++;
});
_calculate();
}
: null,
icon: const Icon(Icons.add_circle),
)
这些代码体现了项目主干:输入、比例、人数都汇入 _calculate(),结果统一渲染到卡片。
总结
tip_calculator 是一个很适合学习 Flutter 表单联动和实时计算的小工具项目。它通过 _billController 管理账单输入,通过 addListener 将输入变化接入 _calculate(),通过 Slider 和快捷按钮设置小费比例,通过加减按钮控制分摊人数,最后用橙色渐变卡片展示小费金额、总金额和人均金额。
从源码结构看,项目的技术重点集中在三条线:第一条是输入监听,账单输入变化会自动触发计算;第二条是状态联动,滑块、快捷按钮和人数按钮都要重新计算结果;第三条是结果展示,金额统一保留两位小数,并通过卡片布局保持清晰层级。
从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。它不需要额外权限,但需要重点验证数字输入、Slider 拖动、按钮禁用边界、页面滚动和结果卡片渲染。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
-
Flutter 官方文档:https://docs.flutter.dev/
-
Dart 官方文档:https://dart.dev/guides
-
OpenHarmony 官网:https://www.openharmony.cn/