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 拖动手势和刻度反馈
按钮 ElevatedButtonIconButton 点击区域和禁用态
结果卡片 大字号金额 小屏和字体缩放

16.2 构建命令参考

flutter clean
flutter pub get
flutter build hap

具体命令取决于所使用的鸿蒙 Flutter 适配环境。对这个项目而言,主要验证输入、滑动、点击和结果展示即可。

16.3 运行验证要点

  1. 应用可以正常启动到首页。
  2. 账单、税费和自定义小费输入稳定。
  3. 小费滑块在 0 到 30 之间正常拖动。
  4. 快捷比例按钮能切换并刷新结果。
  5. 人数增减按钮在 1 和 20 的边界正确禁用。
  6. 结果卡片金额和三项明细显示完整。

鸿蒙适配中,纯 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() 统一处理金额解析、小费优先级和人均拆分,页面只负责把用户输入映射到状态变化。

从鸿蒙适配角度看,这类项目没有复杂原生依赖,主要验证数字输入、滑块手势、按钮禁用态、金额格式和小屏布局即可。只要这些细节稳定,跨端运行体验就会比较可靠。

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


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐