在这里插入图片描述

衣橱里的衣物越来越多,如果没有一个好的分类管理,找衣服就会变成一件头疼的事。今天我们来实现衣橱管家App的分类管理功能,帮助用户清晰地了解自己的衣物构成。

分类管理的价值

分类管理不仅仅是把衣物按类型分开,更重要的是让用户对自己的衣橱有一个全局的认知。通过统计数据和可视化图表,用户可以发现自己的购物偏好,比如是不是上衣买太多了,裤子却不够穿。

这种数据驱动的方式,能帮助用户做出更理性的购物决策,避免冲动消费。

页面整体结构

分类管理页面分为两部分:顶部是饼图展示各分类的占比,下面是分类列表显示具体数量。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../providers/wardrobe_provider.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('分类管理')),
      body: Consumer<WardrobeProvider>(
        builder: (context, provider, child) {
          final stats = provider.getCategoryStats();
          final total = stats.values.fold(0, (a, b) => a + b);

          return SingleChildScrollView(
            padding: EdgeInsets.all(16.w),
            child: Column(
              children: [
                _buildPieChartCard(stats, total),
                SizedBox(height: 16.h),
                ...stats.entries.map((entry) => _buildCategoryCard(entry.key, entry.value, total)),
              ],
            ),
          );
        },
      ),
    );
  }
}

导入了fl_chart包用于绑制饼图,这是Flutter中常用的图表库。Consumer监听Provider变化,getCategoryStats()返回各分类的数量统计。
fold方法计算所有分类数量的总和,用于后面计算百分比。SingleChildScrollView确保内容超出屏幕时可以滚动。

饼图卡片设计

饼图直观地展示各分类的占比,让用户一眼就能看出哪类衣物最多。

Widget _buildPieChartCard(Map<String, int> stats, int total) {
  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        children: [
          Text('衣物分类统计', style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold)),
          SizedBox(height: 16.h),
          SizedBox(
            height: 200.h,
            child: stats.isEmpty
                ? const Center(child: Text('暂无数据'))
                : PieChart(
                    PieChartData(
                      sections: _buildPieSections(stats, total),
                      centerSpaceRadius: 40.r,
                      sectionsSpace: 2,
                    ),
                  ),
          ),
        ],
      ),
    ),
  );
}

饼图高度固定为200,centerSpaceRadius设为40形成环形图效果,比实心饼图更美观。sectionsSpace设为2,让各扇区之间有细微的间隔。
当没有数据时显示"暂无数据"提示,避免空白页面让用户困惑。

饼图扇区数据构建

每个分类对应饼图的一个扇区,颜色、大小和标签都需要动态计算。

List<PieChartSectionData> _buildPieSections(Map<String, int> stats, int total) {
  final colors = [Colors.pink, Colors.blue, Colors.green, Colors.orange, Colors.purple, Colors.teal];
  int index = 0;
  return stats.entries.map((entry) {
    final color = colors[index % colors.length];
    index++;
    return PieChartSectionData(
      value: entry.value.toDouble(),
      title: '${(entry.value / total * 100).toStringAsFixed(0)}%',
      color: color,
      radius: 50.r,
      titleStyle: TextStyle(fontSize: 12.sp, color: Colors.white, fontWeight: FontWeight.bold),
    );
  }).toList();
}

预定义了6种颜色,使用取模运算循环使用,确保分类再多也有颜色可用。value是扇区的数值,决定扇区大小。
title显示百分比,toStringAsFixed(0)保留0位小数。titleStyle设置白色粗体,在彩色背景上清晰可见。

分类列表卡片

每个分类显示为一个卡片,包含图标、名称、进度条和数量。

Widget _buildCategoryCard(String category, int count, int total) {
  final percent = total > 0 ? count / total : 0.0;
  return Card(
    margin: EdgeInsets.only(bottom: 8.h),
    child: ListTile(
      leading: CircleAvatar(
        backgroundColor: const Color(0xFFE91E63).withOpacity(0.1),
        child: Icon(_getCategoryIcon(category), color: const Color(0xFFE91E63)),
      ),
      title: Text(category),
      subtitle: LinearProgressIndicator(
        value: percent,
        backgroundColor: Colors.grey.shade200,
        valueColor: const AlwaysStoppedAnimation(Color(0xFFE91E63)),
      ),
      trailing: Text('$count件', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
    ),
  );
}

ListTile是Material Design的列表项组件,leading放图标,title放分类名,subtitle放进度条,trailing放数量。
进度条直观显示该分类占总数的比例,backgroundColor是灰色底色,valueColor是品牌色填充。

分类图标映射

不同分类使用不同的图标,让用户更容易识别。

IconData _getCategoryIcon(String category) {
  switch (category) {
    case '上衣': return Icons.dry_cleaning;
    case '裤子': return Icons.accessibility_new;
    case '裙子': return Icons.woman;
    case '外套': return Icons.checkroom;
    case '鞋子': return Icons.ice_skating;
    case '配饰': return Icons.watch;
    default: return Icons.checkroom;
  }
}

switch语句根据分类名称返回对应图标。上衣用干洗图标,裤子用人形图标,裙子用女性图标,以此类推。
default分支返回通用的衣架图标,处理未知分类的情况。这种映射方式比if-else更清晰。

添加图例说明

饼图旁边添加图例,说明每种颜色代表什么分类。

Widget _buildLegend(Map<String, int> stats) {
  final colors = [Colors.pink, Colors.blue, Colors.green, Colors.orange, Colors.purple, Colors.teal];
  int index = 0;
  
  return Wrap(
    spacing: 16.w,
    runSpacing: 8.h,
    children: stats.keys.map((category) {
      final color = colors[index % colors.length];
      index++;
      return Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: 12.w,
            height: 12.w,
            decoration: BoxDecoration(
              color: color,
              shape: BoxShape.circle,
            ),
          ),
          SizedBox(width: 4.w),
          Text(category, style: TextStyle(fontSize: 12.sp)),
        ],
      );
    }).toList(),
  );
}

Wrap组件让图例自动换行,适应不同屏幕宽度。每个图例项包含一个彩色圆点和分类名称。
mainAxisSize设为min让Row只占用必要的宽度,多个图例可以并排显示。颜色顺序与饼图扇区保持一致。

点击分类查看详情

点击分类卡片可以查看该分类下的所有衣物。

Widget _buildCategoryCard(String category, int count, int total, WardrobeProvider provider) {
  return Card(
    margin: EdgeInsets.only(bottom: 8.h),
    child: InkWell(
      onTap: () {
        final clothes = provider.clothes.where((c) => c.category == category).toList();
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (_) => CategoryDetailScreen(category: category, clothes: clothes),
          ),
        );
      },
      child: ListTile(
        // ...
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text('$count件', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
            SizedBox(width: 8.w),
            const Icon(Icons.chevron_right, color: Colors.grey),
          ],
        ),
      ),
    ),
  );
}

InkWell包裹ListTile,点击时有水波纹效果。onTap中过滤出该分类的衣物,跳转到分类详情页。
trailing添加了右箭头图标,暗示用户这个卡片可以点击进入详情。

分类详情页

分类详情页展示该分类下的所有衣物,支持排序和筛选。

class CategoryDetailScreen extends StatelessWidget {
  final String category;
  final List<ClothingItem> clothes;

  const CategoryDetailScreen({
    super.key,
    required this.category,
    required this.clothes,
  });

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(category)),
      body: clothes.isEmpty
          ? Center(child: Text('该分类暂无衣物'))
          : GridView.builder(
              padding: EdgeInsets.all(16.w),
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                crossAxisSpacing: 12.w,
                mainAxisSpacing: 12.h,
                childAspectRatio: 0.75,
              ),
              itemCount: clothes.length,
              itemBuilder: (context, index) {
                return _buildClothingCard(context, clothes[index]);
              },
            ),
    );
  }
}

分类详情页接收分类名称和衣物列表作为参数。AppBar标题显示分类名称,让用户知道当前在看哪个分类。
使用GridView展示衣物,与收藏页面保持一致的布局风格。

空分类处理

当某个分类没有衣物时,需要特殊处理。

Widget _buildEmptyCategoryHint(String category) {
  return Card(
    margin: EdgeInsets.only(bottom: 8.h),
    color: Colors.grey.shade100,
    child: ListTile(
      leading: CircleAvatar(
        backgroundColor: Colors.grey.shade300,
        child: Icon(_getCategoryIcon(category), color: Colors.grey),
      ),
      title: Text(category, style: const TextStyle(color: Colors.grey)),
      subtitle: const Text('暂无衣物'),
      trailing: TextButton(
        onPressed: () {
          // 跳转到添加衣物页面,预选该分类
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (_) => AddClothingScreen(preselectedCategory: category),
            ),
          );
        },
        child: const Text('添加'),
      ),
    ),
  );
}

空分类使用灰色调显示,与有数据的分类形成区分。trailing放置添加按钮,引导用户添加该分类的衣物。
跳转到添加页面时传入预选分类,减少用户操作步骤。

分类统计趋势

可以展示分类数量的变化趋势,帮助用户了解购物习惯。

Widget _buildTrendChart(Map<String, List<int>> monthlyStats) {
  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('近6个月趋势', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
          SizedBox(height: 16.h),
          SizedBox(
            height: 150.h,
            child: LineChart(
              LineChartData(
                gridData: FlGridData(show: false),
                titlesData: FlTitlesData(show: false),
                borderData: FlBorderData(show: false),
                lineBarsData: _buildLineData(monthlyStats),
              ),
            ),
          ),
        ],
      ),
    ),
  );
}

LineChart展示折线图,每条线代表一个分类的数量变化。gridData、titlesData、borderData都设为不显示,让图表更简洁。
这个功能需要记录每月的分类统计数据,实际项目中需要持久化存储历史数据。

分类管理操作

用户可能需要添加新分类或重命名现有分类。

void _showCategoryManageDialog(BuildContext context) {
  showModalBottomSheet(
    context: context,
    builder: (context) => Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        ListTile(
          leading: const Icon(Icons.add),
          title: const Text('添加分类'),
          onTap: () {
            Navigator.pop(context);
            _showAddCategoryDialog(context);
          },
        ),
        ListTile(
          leading: const Icon(Icons.edit),
          title: const Text('编辑分类'),
          onTap: () {
            Navigator.pop(context);
            _showEditCategoryDialog(context);
          },
        ),
        ListTile(
          leading: const Icon(Icons.delete),
          title: const Text('删除分类'),
          onTap: () {
            Navigator.pop(context);
            _showDeleteCategoryDialog(context);
          },
        ),
      ],
    ),
  );
}

底部弹窗提供三个操作选项:添加、编辑、删除。每个选项点击后关闭弹窗并打开对应的对话框。
删除分类需要谨慎处理,如果该分类下有衣物,需要提示用户先移动或删除衣物。

添加分类对话框

用户可以自定义新的分类。

void _showAddCategoryDialog(BuildContext context) {
  final controller = TextEditingController();
  
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('添加分类'),
      content: TextField(
        controller: controller,
        decoration: const InputDecoration(
          hintText: '请输入分类名称',
          border: OutlineInputBorder(),
        ),
        autofocus: true,
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        TextButton(
          onPressed: () {
            if (controller.text.isNotEmpty) {
              // 添加分类逻辑
              Navigator.pop(context);
            }
          },
          child: const Text('添加'),
        ),
      ],
    ),
  );
}

autofocus设为true,对话框打开时输入框自动获得焦点,用户可以直接输入。
添加前检查输入是否为空,避免添加空分类。实际项目中还需要检查分类名是否重复。

分类排序功能

用户可以调整分类的显示顺序。

class _CategoryScreenState extends State<CategoryScreen> {
  List<String> _categoryOrder = ['上衣', '裤子', '裙子', '外套', '鞋子', '配饰'];

  Widget _buildReorderableList(Map<String, int> stats) {
    return ReorderableListView(
      onReorder: (oldIndex, newIndex) {
        setState(() {
          if (newIndex > oldIndex) newIndex--;
          final item = _categoryOrder.removeAt(oldIndex);
          _categoryOrder.insert(newIndex, item);
        });
      },
      children: _categoryOrder.map((category) {
        final count = stats[category] ?? 0;
        return _buildCategoryCard(category, count, key: Key(category));
      }).toList(),
    );
  }
}

ReorderableListView支持拖拽排序,长按卡片可以拖动调整位置。onReorder回调处理排序逻辑。
每个子项需要有唯一的Key,这里使用分类名称作为Key。排序结果应该持久化存储。

完整代码整合

把所有功能整合在一起,形成完整的分类管理页面。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../providers/wardrobe_provider.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('分类管理')),
      body: Consumer<WardrobeProvider>(
        builder: (context, provider, child) {
          final stats = provider.getCategoryStats();
          final total = stats.values.fold(0, (a, b) => a + b);

          return SingleChildScrollView(
            padding: EdgeInsets.all(16.w),
            child: Column(
              children: [
                _buildPieChartCard(stats, total),
                SizedBox(height: 16.h),
                ...stats.entries.map((entry) => _buildCategoryCard(entry.key, entry.value, total)),
              ],
            ),
          );
        },
      ),
    );
  }

  Widget _buildPieChartCard(Map<String, int> stats, int total) {
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16.w),
        child: Column(
          children: [
            Text('衣物分类统计', style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold)),
            SizedBox(height: 16.h),
            SizedBox(
              height: 200.h,
              child: stats.isEmpty
                  ? const Center(child: Text('暂无数据'))
                  : PieChart(
                      PieChartData(
                        sections: _buildPieSections(stats, total),
                        centerSpaceRadius: 40.r,
                        sectionsSpace: 2,
                      ),
                    ),
            ),
          ],
        ),
      ),
    );
  }

  List<PieChartSectionData> _buildPieSections(Map<String, int> stats, int total) {
    final colors = [Colors.pink, Colors.blue, Colors.green, Colors.orange, Colors.purple, Colors.teal];
    int index = 0;
    return stats.entries.map((entry) {
      final color = colors[index % colors.length];
      index++;
      return PieChartSectionData(
        value: entry.value.toDouble(),
        title: '${(entry.value / total * 100).toStringAsFixed(0)}%',
        color: color,
        radius: 50.r,
        titleStyle: TextStyle(fontSize: 12.sp, color: Colors.white, fontWeight: FontWeight.bold),
      );
    }).toList();
  }

  Widget _buildCategoryCard(String category, int count, int total) {
    final percent = total > 0 ? count / total : 0.0;
    return Card(
      margin: EdgeInsets.only(bottom: 8.h),
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: const Color(0xFFE91E63).withOpacity(0.1),
          child: Icon(_getCategoryIcon(category), color: const Color(0xFFE91E63)),
        ),
        title: Text(category),
        subtitle: LinearProgressIndicator(
          value: percent,
          backgroundColor: Colors.grey.shade200,
          valueColor: const AlwaysStoppedAnimation(Color(0xFFE91E63)),
        ),
        trailing: Text('$count件', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
      ),
    );
  }

  IconData _getCategoryIcon(String category) {
    switch (category) {
      case '上衣': return Icons.dry_cleaning;
      case '裤子': return Icons.accessibility_new;
      case '裙子': return Icons.woman;
      case '外套': return Icons.checkroom;
      case '鞋子': return Icons.ice_skating;
      case '配饰': return Icons.watch;
      default: return Icons.checkroom;
    }
  }
}

代码结构清晰,饼图和列表分别封装成独立方法。_buildPieSections处理饼图数据,_buildCategoryCard处理列表项。
使用fl_chart库绑制饼图,需要在pubspec.yaml中添加依赖。图表配置简洁,只保留必要的元素。

写在最后

分类管理功能帮助用户从宏观角度了解自己的衣橱构成。通过饼图和列表的结合,用户可以直观地看到各分类的占比和数量。

数据可视化是提升用户体验的重要手段,它让枯燥的数字变得生动有趣。希望这个功能能帮助用户更好地管理自己的衣橱。

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

Logo

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

更多推荐