Flutter三方库适配OpenHarmony【tip_calculator】小费计算器项目完整实战

Flutter三方库适配OpenHarmony【tip_calculator】小费计算器项目完整实战

前言

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

tip_calculator 是一个基于 Flutter 实现的 Tip Calculator 小费计算器 。项目核心源码位于 lib/main.dart,同时保留了 ohos OpenHarmony 平台工程目录,适合用来学习 Flutter 表单输入实时计算Slider 滑块联动快捷百分比按钮分摊人数边界控制OpenHarmony 平台承载

小费计算器是一个典型的轻量财务工具。它不需要网络、数据库或复杂状态管理,但需要把输入、滑块、按钮、人数、金额公式和结果卡片组织成稳定的数据流。相比只点击一次按钮的计算器,它更强调 输入监听状态联动

本文重点回答三个问题:

  1. tip_calculator 如何用 TextEditingController.addListener 监听账单金额变化。
  2. Flutter 如何通过 Slider、快捷按钮和加减按钮驱动小费比例与分摊人数变化。
  3. 这个项目适配 OpenHarmony 时,如何验证输入、滑动、按钮边界和金额展示。

图示说明:本文聚焦 Flutter 小费计算器的输入联动、金额计算和 OpenHarmony 工程承载,核心源码位于 lib/main.dart


一、背景与目标

1.1 项目功能概览

tip_calculator 是一个轻量账单小费计算工具。用户输入账单金额后,可以通过滑块或快捷按钮调整小费百分比,并使用加减按钮调整分摊人数。页面会实时计算小费金额、总金额和人均金额。

当前项目真实支持的功能包括:

  • 输入账单金额。
  • 使用 $ 前缀提示金额单位。
  • 默认账单金额为 0
  • 默认小费比例为 15%
  • 使用 Slider0%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 项目目标

这个项目的目标不是实现复杂记账系统,而是把账单小费计算的核心链路做完整:

  1. 用户输入账单金额。
  2. 用户设置小费百分比。
  3. 用户设置分摊人数。
  4. 应用计算小费金额。
  5. 应用计算总金额。
  6. 应用计算人均金额。
  7. 页面实时展示结果。

关键点:小费计算器的难点不在公式,而在多个输入源共同驱动同一组结果状态。输入框、滑块、快捷按钮和人数按钮都要汇入同一个 _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'),
    );
  }
}

这里有三个关键信息:

  1. 应用标题是 Tip Calculator
  2. 主题种子色使用 Colors.orange
  3. 首页是 TipCalculatorHomePage,输入和计算状态从首页 State 中维护。

3.3 Material 3 主题

dart 复制代码
theme: ThemeData(
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
  useMaterial3: true,
),

useMaterial3: true 会影响 AppBarTextFieldSliderElevatedButtonCard 等组件的默认风格。当前项目用橙色贯穿滑块、按钮选中态和结果卡片。


四、页面状态设计

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 侧主要验证:

  1. 账单输入框是否能正常唤起数字键盘。
  2. Slider 是否能顺畅拖动。
  3. 快捷百分比按钮是否能正确选中。
  4. 分摊人数边界是否正确禁用按钮。
  5. 结果卡片金额是否实时更新。
  6. 小屏幕和输入法弹出时页面是否能滚动。

十三、测试与验证

13.1 页面验证路径

小费计算器的核心验证路径如下:

  1. 启动应用,标题显示 Tip Calculator
  2. 账单输入框默认显示 0
  3. 默认小费比例显示 15%
  4. 默认分摊人数显示 1
  5. 输入账单金额 100
  6. 结果卡片显示小费 $15.00
  7. 总金额显示 $115.00
  8. 人均金额显示 $115.00
  9. 将人数增加到 2,人均金额变为 $57.50
  10. 点击 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 输入链路

  1. 用户编辑账单输入框。
  2. _billController.text 发生变化。
  3. addListener 触发 _calculate()
  4. double.tryParse 解析账单金额。
  5. 解析失败时账单金额按 0 处理。

15.2 小费比例链路

  1. 用户拖动 Slider。
  2. _tipPercent 更新为滑块值。
  3. 页面显示新的百分比。
  4. 调用 _calculate()
  5. 结果卡片金额更新。

15.3 分摊人数链路

  1. 用户点击加号或减号。
  2. _splitCount 在 1 到 20 之间变化。
  3. 调用 _calculate()
  4. _perPerson 根据新人数重新计算。
  5. 结果卡片显示新的人均金额。

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 拖动、按钮禁用边界、页面滚动和结果卡片渲染。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

相关推荐
yuegu7771 小时前
HarmonyOS应用<节气通>开发第6篇:节气详情页(下)——诗词与养生
华为·harmonyos
慧海灵舟1 小时前
鸿蒙南向开发教程Day 2:创建自己的 Hello World 工程
华为·harmonyos·写文章,赢小鸿ai
小铁-Android2 小时前
Visual Studio Code创建Flutter项目时包名组织名更改
vscode·flutter
颜淡慕潇2 小时前
鸿蒙 PC的 vcpkg 交叉编译库在x86_64宿主环境下的AI自动化验证方案
人工智能·自动化·harmonyos
再见6583 小时前
HarmonyOS NEXT 实战:从零开发一款密码生成器应用
华为·harmonyos
李二。3 小时前
鸿蒙原生ArkTS布局方式之ColumnBaseline垂直排列
华为·harmonyos
韩曙亮3 小时前
【错误记录】flutter attach 附加设备 执行报错 ( 附加设备注意事项 )
android·javascript·flutter·flutter attach