在这里插入图片描述

我自己记账的时候,最头疼的就是分类。有些App预设的分类太少,有些又太多太乱。后来我发现,最好的方案是让用户自己管理分类,想加什么加什么,不喜欢的可以删掉。所以在做这个生活助手App时,我特别重视分类管理这个功能。

为什么分类管理这么重要

在开始写代码之前,我先想清楚了这个功能的价值。

第一个是个性化需求。每个人的消费习惯不一样,需要的分类也不同。比如我经常点外卖,就需要一个"外卖"分类;有人经常买书,就需要一个"图书"分类。预设的分类很难满足所有人的需求。

第二个是数据准确性。分类越细致,统计出来的数据就越准确。如果只有一个"其他"分类,时间长了就不知道钱花哪儿了。但如果分类太多,记账时又要花很多时间选择。所以要让用户自己掌控分类的粒度。

第三个是视觉识别。每个分类配上不同的图标和颜色,记账时一眼就能找到。这比纯文字列表效率高多了。我自己用的时候,看到橙色的餐饮图标,手指就自动点过去了,根本不用思考。

功能设计的思路

在设计这个功能时,我考虑了以下几个方面。

支出和收入分开管理

支出和收入的分类是不同的。支出有餐饮、交通、购物等,收入有工资、奖金、投资等。如果混在一起,会很乱。所以我用了TabBar,把两种分类分成两个标签页。

图标和颜色的可视化

每个分类都有自己的图标和颜色。图标用Flutter内置的Material Icons,颜色从预设的几种里选。这样既统一又有辨识度。

编辑和删除功能

用户可以编辑分类的名称、图标、颜色,也可以删除不需要的分类。但系统预设的分类不能删除,避免用户误操作后找不回来。

添加新分类

右下角的悬浮按钮,点击可以添加新分类。弹出一个对话框,让用户输入名称、选择图标和颜色。

页面整体结构

先看页面的基本框架:

class CategoryManagementPage extends StatelessWidget {
  const CategoryManagementPage({super.key});

  
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('分类管理'),
          bottom: const TabBar(
            tabs: [
              Tab(text: '支出分类'),
              Tab(text: '收入分类'),
            ],
          ),
        ),
        body: TabBarView(
          children: [
            _buildCategoryList(true),
            _buildCategoryList(false),
          ],
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {},
          child: const Icon(Icons.add),
        ),
      ),
    );
  }
}

DefaultTabController的使用

DefaultTabController是Flutter提供的标签页控制器,length: 2表示有两个标签。它会自动管理标签的切换,不需要我们手动处理状态。

TabBar和TabBarView的配合

TabBar放在AppBarbottom位置,显示两个标签:“支出分类"和"收入分类”。TabBarView是标签对应的内容,两个标签对应两个页面。

当用户点击标签时,TabBarView会自动切换到对应的页面。这个切换是有动画的,从左往右或从右往左滑动,体验很流畅。

FloatingActionButton的位置

悬浮按钮固定在右下角,无论切换到哪个标签,都能看到。这样用户随时可以添加新分类,不用在菜单里找。

分类列表的构建

两个标签页的内容是类似的,只是数据不同。我用一个方法来构建,传入一个布尔值区分是支出还是收入:

Widget _buildCategoryList(bool isExpense) {
  final categories = isExpense
      ? [
          {'name': '餐饮', 'icon': Icons.restaurant, 'color': Colors.orange},
          {'name': '交通', 'icon': Icons.directions_car, 'color': Colors.blue},
          {'name': '购物', 'icon': Icons.shopping_bag, 'color': Colors.purple},
          {'name': '娱乐', 'icon': Icons.movie, 'color': Colors.pink},
          {'name': '住房', 'icon': Icons.home, 'color': Colors.green},
          {'name': '医疗', 'icon': Icons.local_hospital, 'color': Colors.red},
        ]
      : [
          {'name': '工资', 'icon': Icons.work, 'color': Colors.green},
          {'name': '奖金', 'icon': Icons.card_giftcard, 'color': Colors.amber},
          {'name': '投资', 'icon': Icons.trending_up, 'color': Colors.blue},
          {'name': '其他', 'icon': Icons.more_horiz, 'color': Colors.grey},
        ];

  return ListView.builder(
    padding: EdgeInsets.all(16.w),
    itemCount: categories.length,
    itemBuilder: (context, index) {
      final category = categories[index];
      return _buildCategoryCard(category);
    },
  );
}

三元表达式的妙用

isExpense ? [...] : [...]这个三元表达式根据参数返回不同的数据。如果是支出分类,返回餐饮、交通等;如果是收入分类,返回工资、奖金等。

这样一个方法就能处理两种情况,代码复用率高。

支出分类的选择

支出分类我选了6个常用的:餐饮、交通、购物、娱乐、住房、医疗。这是根据大多数人的消费习惯总结的。

  • 餐饮:包括外卖、聚餐、买菜等,用橙色,因为橙色让人联想到食物
  • 交通:包括打车、公交、地铁、加油等,用蓝色,蓝色给人移动的感觉
  • 购物:包括衣服、日用品、电子产品等,用紫色,紫色比较时尚
  • 娱乐:包括电影、游戏、旅游等,用粉色,粉色比较轻松愉快
  • 住房:包括房租、物业费、装修等,用绿色,绿色代表家的温馨
  • 医疗:包括看病、买药、体检等,用红色,红色是医疗的标志色

收入分类的选择

收入分类相对简单,4个就够了:工资、奖金、投资、其他。

  • 工资:固定收入,用绿色,绿色代表稳定
  • 奖金:额外收入,用琥珀色,琥珀色给人惊喜的感觉
  • 投资:理财收益,用蓝色,蓝色代表理性
  • 其他:其他来源,用灰色,灰色比较中性

ListView.builder的使用

ListView.builder是懒加载的列表,只会构建可见的item。虽然这里分类数量不多,但养成好习惯总是对的。

分类卡片的设计

每个分类用一个卡片来展示,这个卡片的设计我反复调整了好几次:

Widget _buildCategoryCard(Map<String, dynamic> category) {
  return Container(
    margin: EdgeInsets.only(bottom: 12.h),
    padding: EdgeInsets.all(16.w),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(12.r),
    ),
    child: Row(
      children: [
        Container(
          padding: EdgeInsets.all(12.w),
          decoration: BoxDecoration(
            color: (category['color'] as Color).withOpacity(0.1),
            borderRadius: BorderRadius.circular(12.r),
          ),
          child: Icon(
            category['icon'] as IconData,
            color: category['color'] as Color,
            size: 28.sp,
          ),
        ),
        SizedBox(width: 16.w),
        Expanded(
          child: Text(
            category['name'] as String,
            style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold),
          ),
        ),
        IconButton(
          icon: const Icon(Icons.edit),
          onPressed: () {},
        ),
      ],
    ),
  );
}

图标容器的设计

图标外面包了一个Container,背景色是分类颜色的10%透明度。这样既有颜色区分,又不会太刺眼。比如餐饮的橙色,背景就是淡淡的橙色。

borderRadius: BorderRadius.circular(12.r)让容器变成圆角矩形,和整体的圆润风格保持一致。

图标的大小和颜色

图标大小设置为28.sp,不大不小刚刚好。颜色用分类的主题色,和背景色呼应。

分类名称的布局

分类名称用Expanded包裹,这样它会占据剩余的所有空间。如果名称很长,会自动换行,不会被截断。

fontWeight: FontWeight.bold让文字加粗,更醒目。

编辑按钮的位置

右边放了一个编辑按钮,点击可以修改分类。用IconButton而不是普通的Icon,因为需要点击事件。

添加新分类的交互

右下角的悬浮按钮,点击后应该弹出一个对话框,让用户输入分类信息。虽然代码里onPressed是空的,但实际项目中应该这样实现:

void _showAddCategoryDialog(BuildContext context) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('添加分类'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextField(
            decoration: const InputDecoration(
              labelText: '分类名称',
              border: OutlineInputBorder(),
            ),
          ),
          SizedBox(height: 16.h),
          const Text('选择图标'),
          // 这里应该放一个图标选择器
          SizedBox(height: 16.h),
          const Text('选择颜色'),
          // 这里应该放一个颜色选择器
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        TextButton(
          onPressed: () {
            // 保存新分类
            Navigator.pop(context);
          },
          child: const Text('添加'),
        ),
      ],
    ),
  );
}

对话框的内容

对话框包含三个部分:输入框、图标选择器、颜色选择器。

输入框用TextField,设置了labelTextborder,看起来更正式。

图标选择器可以用一个网格布局,展示所有可用的图标。用户点击选中一个。

颜色选择器可以用一排圆形色块,用户点击选中一个颜色。

保存逻辑

点击"添加"按钮后,应该:

  1. 获取输入框的内容
  2. 验证输入是否为空
  3. 创建新的分类对象
  4. 添加到分类列表
  5. 保存到数据库
  6. 关闭对话框
  7. 刷新列表

编辑分类的实现

每个分类卡片右边的编辑按钮,点击后应该弹出类似的对话框,但是预填充当前分类的信息:

void _showEditCategoryDialog(BuildContext context, Map<String, dynamic> category) {
  final nameController = TextEditingController(text: category['name'] as String);
  
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('编辑分类'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextField(
            controller: nameController,
            decoration: const InputDecoration(
              labelText: '分类名称',
              border: OutlineInputBorder(),
            ),
          ),
          // 图标和颜色选择器,预选中当前的图标和颜色
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        TextButton(
          onPressed: () {
            // 更新分类信息
            Navigator.pop(context);
          },
          child: const Text('保存'),
        ),
      ],
    ),
  );
}

TextEditingController的使用

TextEditingController可以控制输入框的内容。创建时传入text参数,输入框就会预填充这个文字。

用户修改后,通过nameController.text可以获取新的内容。

预选中图标和颜色

图标选择器和颜色选择器应该预选中当前分类的图标和颜色。这样用户知道当前的设置是什么,可以选择保持不变或者修改。

删除分类的功能

长按分类卡片,应该弹出一个确认对话框,询问是否删除:

void _showDeleteConfirmDialog(BuildContext context, Map<String, dynamic> category) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('删除分类'),
      content: Text('确定要删除"${category['name']}"分类吗?'),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        TextButton(
          onPressed: () {
            // 删除分类
            Navigator.pop(context);
          },
          style: TextButton.styleFrom(foregroundColor: Colors.red),
          child: const Text('删除'),
        ),
      ],
    ),
  );
}

确认对话框的必要性

删除是不可逆的操作,必须让用户确认。对话框的内容要明确告诉用户要删除哪个分类。

删除按钮用红色,警示用户这是危险操作。

删除的限制

系统预设的分类不应该允许删除。可以在分类数据里加个isSystem字段,标记是否是系统分类。如果是,长按时不弹出删除对话框,或者弹出提示"系统分类不能删除"。

数据持久化的考虑

分类数据应该保存到本地数据库,这样应用关闭后再打开,分类还在。

使用SQLite存储

可以用sqflite包来操作SQLite数据库。创建一个categories表,包含以下字段:

  • id:主键,自增
  • name:分类名称
  • icon:图标名称(存字符串,使用时转换成IconData)
  • color:颜色值(存整数,使用时转换成Color)
  • type:类型(0表示支出,1表示收入)
  • isSystem:是否系统分类(0表示用户自定义,1表示系统预设)
  • sortOrder:排序顺序

初始化预设分类

应用第一次启动时,应该插入预设的分类。可以在数据库初始化时检查categories表是否为空,如果为空就插入预设数据。

分类的排序

用户可能想调整分类的顺序,把常用的放在前面。可以加个拖拽排序的功能,用ReorderableListView实现。

图标选择器的实现

添加和编辑分类时,需要一个图标选择器。可以用GridView展示所有可用的图标:

Widget _buildIconPicker(IconData? selectedIcon, Function(IconData) onSelect) {
  final icons = [
    Icons.restaurant,
    Icons.directions_car,
    Icons.shopping_bag,
    Icons.movie,
    Icons.home,
    Icons.local_hospital,
    Icons.work,
    Icons.card_giftcard,
    Icons.trending_up,
    Icons.more_horiz,
    // 更多图标...
  ];

  return GridView.builder(
    shrinkWrap: true,
    gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 5,
      mainAxisSpacing: 8,
      crossAxisSpacing: 8,
    ),
    itemCount: icons.length,
    itemBuilder: (context, index) {
      final icon = icons[index];
      final isSelected = icon == selectedIcon;
      
      return GestureDetector(
        onTap: () => onSelect(icon),
        child: Container(
          decoration: BoxDecoration(
            color: isSelected ? Colors.blue : Colors.grey[200],
            borderRadius: BorderRadius.circular(8),
          ),
          child: Icon(
            icon,
            color: isSelected ? Colors.white : Colors.black,
          ),
        ),
      );
    },
  );
}

GridView的布局

crossAxisCount: 5表示每行显示5个图标。mainAxisSpacingcrossAxisSpacing设置间距。

选中状态的视觉反馈

选中的图标背景色是蓝色,未选中的是灰色。这样用户一眼就能看出选了哪个。

颜色选择器的实现

颜色选择器比图标选择器简单,就是一排圆形色块:

Widget _buildColorPicker(Color? selectedColor, Function(Color) onSelect) {
  final colors = [
    Colors.red,
    Colors.orange,
    Colors.yellow,
    Colors.green,
    Colors.blue,
    Colors.purple,
    Colors.pink,
    Colors.brown,
    Colors.grey,
  ];

  return Row(
    mainAxisAlignment: MainAxisAlignment.spaceAround,
    children: colors.map((color) {
      final isSelected = color == selectedColor;
      
      return GestureDetector(
        onTap: () => onSelect(color),
        child: Container(
          width: 40,
          height: 40,
          decoration: BoxDecoration(
            color: color,
            shape: BoxShape.circle,
            border: isSelected ? Border.all(color: Colors.black, width: 3) : null,
          ),
        ),
      );
    }).toList(),
  );
}

圆形色块的实现

shape: BoxShape.circle让容器变成圆形。选中的色块加个黑色边框,未选中的没有边框。

颜色的选择

我选了9种常用的颜色,基本能满足需求。如果想要更多颜色,可以加个"自定义颜色"选项,弹出一个完整的颜色选择器。

实际使用体验

这个功能我自己用了一段时间,感觉还不错。预设的分类基本够用,偶尔需要加个新分类也很方便。

图标和颜色的可视化很重要,记账时不用看文字,看图标就知道是哪个分类。这大大提高了记账的效率。

有一次我想加个"宠物"分类,因为养了只猫,每个月猫粮、猫砂、看病的花费不少。点击悬浮按钮,输入名称,选了个猫爪图标,选了粉色,几秒钟就搞定了。

可以改进的地方

如果要做得更完善,可以考虑以下几点。

分类的统计信息

在分类卡片上显示这个分类的总支出或总收入,以及占比。这样用户能知道哪个分类花钱最多。

分类的使用频率排序

把最常用的分类排在前面,不常用的排在后面。可以根据使用次数自动排序,或者让用户手动拖拽排序。

分类的图标库扩展

Material Icons虽然很全,但有些场景还是不够用。可以支持自定义图标,让用户上传图片作为图标。

分类的导入导出

用户可能在多个设备上使用,或者想分享自己的分类设置给朋友。可以加个导入导出功能,把分类数据导出成JSON文件。

分类的预设模板

除了默认的分类,可以提供几套预设模板,比如"学生模板"、“上班族模板”、“家庭主妇模板”。用户可以一键导入,省去手动添加的麻烦。

小结

今天实现了分类管理功能,用到了标签页、列表、对话框等组件。核心是用TabBarTabBarView分别管理支出和收入分类,用图标和颜色增强视觉识别。

这个功能虽然不是最核心的,但对用户体验影响很大。分类设置得好,记账就快;分类设置得乱,记账就慢。让用户自己管理分类,是最灵活的方案。

在实现过程中,我特别注重可视化。每个分类都有自己的图标和颜色,记账时一眼就能找到。这种细节上的优化,能大大提高用户的使用效率。

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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐