Flutter 框架跨平台鸿蒙开发 - 家庭决策投票器应用
摘要: 家庭决策投票器是一款基于Flutter开发的跨平台应用,旨在通过民主投票解决家庭日常决策问题。应用提供6类投票主题(出行、餐饮、影视等),支持创建多选项投票、实时统计结果和匿名投票功能。采用Material Design 3设计规范,以紫色为主色调,包含首页、投票、历史和成员四大模块。技术架构采用三层结构(表现层/业务层/数据层),核心功能包括投票管理、结果计算和成员统计。该应用可运行于鸿
欢迎加入开源鸿蒙跨平台社区:
https://openharmonycrossplatform.csdn.net
一、项目概述
运行效果图





1.1 应用简介
家庭决策投票器是一款社交工具类应用,致力于帮助家庭以民主方式解决日常决策问题。无论是周末去哪里玩、晚饭吃什么、看什么电影,都可以通过投票来决定。支持多种投票分类、实时统计结果、匿名投票等功能,让家庭决策更加公平透明,避免争吵,增进家庭和谐。
应用以优雅的紫色为主色调,象征智慧与民主。涵盖首页导航、进行中投票、投票历史、家庭成员四大模块。用户可以发起投票、参与投票、查看结果,让每一个家庭成员都有发言权。
1.2 核心功能
| 功能模块 | 功能描述 | 实现方式 |
|---|---|---|
| 投票分类 | 6种投票类型分类 | 枚举定义 |
| 投票创建 | 自定义选项创建 | 表单提交 |
| 实时投票 | 家庭成员参与投票 | 状态更新 |
| 结果统计 | 实时统计投票结果 | 数据计算 |
| 投票历史 | 历史投票记录查看 | 列表展示 |
| 成员统计 | 成员投票参与统计 | 数据分析 |
1.3 投票分类定义
| 序号 | 分类名称 | Emoji | 色值 |
|---|---|---|---|
| 1 | 出行游玩 | 🚗 | #4CAF50 |
| 2 | 餐饮美食 | 🍽️ | #FF9800 |
| 3 | 影视娱乐 | 🎬 | #E91E63 |
| 4 | 购物消费 | 🛒 | #2196F3 |
| 5 | 家庭活动 | 🏠 | #9C27B0 |
| 6 | 其他事项 | 📌 | #607D8B |
1.4 投票状态定义
| 序号 | 状态名称 | Emoji | 色值 |
|---|---|---|---|
| 1 | 进行中 | ⏳ | #4CAF50 |
| 2 | 已结束 | ✅ | #9E9E9E |
| 3 | 待开始 | 🕐 | #FFC107 |
1.5 家庭成员定义
| 序号 | 成员名称 | Emoji | 色值 |
|---|---|---|---|
| 1 | 爸爸 | 👨 | #2196F3 |
| 2 | 妈妈 | 👩 | #E91E63 |
| 3 | 爷爷 | 👴 | #9C27B0 |
| 4 | 奶奶 | 👵 | #FF5722 |
| 5 | 儿子 | 👦 | #00BCD4 |
| 6 | 女儿 | 👧 | #FF9800 |
1.6 技术栈
| 技术领域 | 技术选型 | 版本要求 |
|---|---|---|
| 开发框架 | Flutter | >= 3.0.0 |
| 编程语言 | Dart | >= 2.17.0 |
| 设计规范 | Material Design 3 | - |
| 动画控制 | AnimationController | - |
| 状态管理 | setState | - |
| 目标平台 | 鸿蒙OS / Web | API 21+ |
1.7 项目结构
lib/
└── main_family_voting.dart
├── FamilyVotingApp # 应用入口
├── VoteCategory # 投票分类枚举
├── VoteStatus # 投票状态枚举
├── FamilyMember # 家庭成员枚举
├── VoteOption # 投票选项模型
├── Vote # 投票模型
├── FamilyVotingHomePage # 主页面(底部导航)
├── _buildHomePage # 首页模块
├── _buildVotesPage # 投票页模块
├── _buildHistoryPage # 历史页模块
├── _buildMembersPage # 成员页模块
├── VoteDetailPage # 投票详情页面
└── CreateVoteSheet # 创建投票弹窗
二、系统架构
2.1 整体架构图
2.2 类图设计
2.3 页面导航流程
2.4 投票流程时序
三、核心模块设计
3.1 数据模型设计
3.1.1 投票分类枚举 (VoteCategory)
enum VoteCategory {
travel('出行游玩', '🚗', Color(0xFF4CAF50)),
food('餐饮美食', '🍽️', Color(0xFFFF9800)),
movie('影视娱乐', '🎬', Color(0xFFE91E63)),
shopping('购物消费', '🛒', Color(0xFF2196F3)),
activity('家庭活动', '🏠', Color(0xFF9C27B0)),
other('其他事项', '📌', Color(0xFF607D8B));
final String label;
final String emoji;
final Color color;
const VoteCategory(this.label, this.emoji, this.color);
}
3.1.2 投票状态枚举 (VoteStatus)
enum VoteStatus {
ongoing('进行中', '⏳', Color(0xFF4CAF50)),
ended('已结束', '✅', Color(0xFF9E9E9E)),
pending('待开始', '🕐', Color(0xFFFFC107));
final String label;
final String emoji;
final Color color;
const VoteStatus(this.label, this.emoji, this.color);
}
3.1.3 投票选项模型 (VoteOption)
class VoteOption {
final String id; // 选项ID
final String text; // 选项文本
final List<FamilyMember> voters; // 投票人列表
VoteOption({
required this.id,
required this.text,
this.voters = const [],
});
VoteOption copyWith({List<FamilyMember>? voters}) {
return VoteOption(
id: id,
text: text,
voters: voters ?? this.voters,
);
}
}
3.1.4 投票模型 (Vote)
class Vote {
final String id; // 投票ID
final String title; // 投票标题
final String description; // 投票描述
final VoteCategory category; // 投票分类
final VoteStatus status; // 投票状态
final List<VoteOption> options; // 投票选项
final DateTime createdAt; // 创建时间
final DateTime? endTime; // 截止时间
final bool isAnonymous; // 是否匿名
final bool allowMultiple; // 是否允许多选
int get totalVotes => options.fold(0, (sum, opt) => sum + opt.voters.length);
}
3.1.5 投票分类分布
3.2 页面结构设计
3.2.1 主页面布局
3.2.2 首页结构
3.2.3 投票详情页结构
3.2.4 历史页结构
3.3 投票逻辑流程
3.4 结果计算逻辑
四、UI设计规范
4.1 配色方案
应用以优雅的紫色为主色调,象征智慧与民主:
| 颜色类型 | 色值 | 用途 |
|---|---|---|
| 主色 | #673AB7 (DeepPurple) | 导航、主题元素 |
| 辅助色 | #7E57C2 | 次要元素 |
| 强调色 | #B39DDB | 背景渐变 |
| 背景色 | #EDE7F6 → #B39DDB | 首页渐变背景 |
| 卡片背景 | #FFFFFF | 内容卡片 |
4.2 分类专属配色
| 分类 | 色值 | 视觉效果 |
|---|---|---|
| 出行游玩 | #4CAF50 | 自然绿色 |
| 餐饮美食 | #FF9800 | 温暖橙色 |
| 影视娱乐 | #E91E63 | 活力粉色 |
| 购物消费 | #2196F3 | 商务蓝色 |
| 家庭活动 | #9C27B0 | 优雅紫色 |
| 其他事项 | #607D8B | 中性灰色 |
4.3 字体规范
| 元素 | 字号 | 字重 | 颜色 |
|---|---|---|---|
| 页面标题 | 22px | Bold | #000000 |
| 投票标题 | 16px | Bold | #000000 |
| 选项文字 | 16px | Medium | 分类色 |
| 统计数字 | 24px | Bold | 主题色 |
| 描述文字 | 12px | Regular | #757575 |
4.4 组件规范
4.4.1 投票卡片
┌─────────────────────────────────────┐
│ [🚗 出行游玩] [⏳ 进行中]│
│ │
│ 周末去哪里玩? │
│ 这周末全家一起出去玩,大家投票决定 │
│ │
│ 🗳️ 4票 ⏰ 剩余1天 │
│ │
│ ┌─────────────────────────────┐ │
│ │ 🏆 领先:公园野餐 2票 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
4.4.2 投票选项卡片
┌─────────────────────────────────────┐
│ 公园野餐 [🏆 领先] 2票 │
│ ████████████░░░░░░░░ 50.0% │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ 游乐场 1票 │
│ ██████░░░░░░░░░░░░░░ 25.0% │
└─────────────────────────────────────┘
4.4.3 历史卡片
┌─────────────────────────────────────┐
│ 🎬 看什么电影? [🏆 喜剧片] │
│ 5人参与 · 1/15 │
│ │
│ [动作片 40%] [喜剧片 60%] [动画片 0%]│
└─────────────────────────────────────┘
4.4.4 成员卡片
┌─────────────────────────────────────┐
│ ┌────┐ 爸爸 [3票] │
│ │ 👨 │ 参与投票 3 次 │
│ └────┘ │
└─────────────────────────────────────┘
4.4.5 创建投票表单
┌─────────────────────────────────────┐
│ 发起投票 [×] │
│ │
│ ┌─────────────────────────────┐ │
│ │ 投票标题 │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ 投票描述 │ │
│ └─────────────────────────────┘ │
│ │
│ 投票分类 │
│ [🚗 出行] [🍽️ 美食] [🎬 影视]... │
│ │
│ 投票选项 [+添加]│
│ ┌─────────────────────────────┐ │
│ │ 选项 1 × │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ 选项 2 × │ │
│ └─────────────────────────────┘ │
│ │
│ [○] 允许多选 │
│ [○] 匿名投票 │
│ │
│ ┌─────────────────────────────┐ │
│ │ 创建投票 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
五、核心功能实现
5.1 投票卡片实现
Widget _buildVoteCard(Vote vote) {
final maxVotes = vote.options.map((o) => o.voters.length).reduce((a, b) => a > b ? a : b);
final winningOption = vote.options.firstWhere(
(o) => o.voters.length == maxVotes,
orElse: () => vote.options.first,
);
return GestureDetector(
onTap: () => _viewVote(vote),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: vote.category.color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(vote.category.emoji, style: const TextStyle(fontSize: 12)),
const SizedBox(width: 4),
Text(vote.category.label, style: TextStyle(fontSize: 10, color: vote.category.color)),
],
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: vote.status.color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text('${vote.status.emoji} ${vote.status.label}', style: TextStyle(fontSize: 10, color: vote.status.color)),
),
],
),
const SizedBox(height: 12),
Text(vote.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
],
),
),
);
}
5.2 进行中投票指示器
Widget _buildOngoingVotes(List<Vote> ongoingVotes) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AnimatedBuilder(
animation: _pulseController,
builder: (context, child) {
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.2 + _pulseController.value * 0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.circle, color: Colors.green, size: 8),
);
},
),
const SizedBox(width: 8),
const Text('进行中的投票', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 12),
...ongoingVotes.map((vote) => _buildVoteCard(vote)),
],
);
}
5.3 投票选项实现
Widget _buildOptionsCard(int maxVotes) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('投票选项', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
..._currentVote.options.map((option) {
final percentage = _currentVote.totalVotes > 0
? option.voters.length / _currentVote.totalVotes
: 0.0;
final isLeading = option.voters.length == maxVotes && option.voters.isNotEmpty;
final isSelected = _selectedOption == option.id || widget.myVotes.contains(option.id);
return GestureDetector(
onTap: _currentVote.status == VoteStatus.ongoing && !widget.myVotes.contains(option.id)
? () { setState(() { _selectedOption = option.id; }); }
: null,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected ? _currentVote.category.color.withValues(alpha: 0.1) : Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? _currentVote.category.color : isLeading ? Colors.green : Colors.transparent,
width: 2,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(option.text, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500))),
if (isLeading) Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: Colors.amber.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8)),
child: const Row(children: [
Icon(Icons.emoji_events, color: Colors.amber, size: 14),
SizedBox(width: 2),
Text('领先', style: TextStyle(fontSize: 10, color: Colors.amber)),
]),
),
const SizedBox(width: 8),
Text('${option.voters.length}票', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: isLeading ? Colors.green : Colors.grey[700])),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: percentage,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(isLeading ? Colors.green : _currentVote.category.color),
minHeight: 6,
),
),
],
),
),
);
}),
],
);
}
5.4 投票提交实现
void _castVote(Vote vote, String optionId) {
setState(() {
final voteIndex = _votes.indexWhere((v) => v.id == vote.id);
if (voteIndex != -1) {
final updatedOptions = vote.options.map((option) {
if (option.id == optionId) {
final newVoters = List<FamilyMember>.from(option.voters);
newVoters.add(FamilyMember.dad);
return option.copyWith(voters: newVoters);
}
return option;
}).toList();
_votes[voteIndex] = vote.copyWith(options: updatedOptions);
_myVotes.add(optionId);
}
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('投票成功!'), backgroundColor: Color(0xFF673AB7)),
);
}
5.5 历史记录实现
Widget _buildHistoryCard(Vote vote) {
final maxVotes = vote.options.map((o) => o.voters.length).reduce((a, b) => a > b ? a : b);
final winningOption = vote.options.firstWhere(
(o) => o.voters.length == maxVotes,
orElse: () => vote.options.first,
);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(vote.category.emoji, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Expanded(child: Text(vote.title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold))),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.green.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.emoji_events, color: Colors.amber, size: 14),
const SizedBox(width: 4),
Text(winningOption.text, style: const TextStyle(fontSize: 10, color: Colors.green)),
],
),
),
],
),
const SizedBox(height: 8),
Text('${vote.totalVotes}人参与 · ${_formatDate(vote.createdAt)}', style: TextStyle(fontSize: 10, color: Colors.grey[600])),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: vote.options.map((option) {
final percentage = vote.totalVotes > 0 ? (option.voters.length / vote.totalVotes * 100).toInt() : 0;
final isWinner = option.voters.length == maxVotes && option.voters.isNotEmpty;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isWinner ? Colors.green.withValues(alpha: 0.1) : Colors.grey[100],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: isWinner ? Colors.green : Colors.transparent),
),
child: Text('${option.text} $percentage%', style: TextStyle(fontSize: 10, color: isWinner ? Colors.green : Colors.grey[700])),
);
}).toList(),
),
],
),
);
}
六、交互设计
6.1 投票创建流程
6.2 投票参与流程
6.3 结果统计流程
七、扩展功能规划
7.1 后续版本规划
7.2 功能扩展建议
7.2.1 评论讨论功能
评论功能:
- 投票下发表意见
- 回复他人评论
- 点赞功能
- 表情支持
7.2.2 投票提醒通知
通知功能:
- 新投票提醒
- 投票截止提醒
- 结果公布通知
- 未投票提醒
7.2.3 权重投票
权重功能:
- 成员权重设置
- 按年龄分配权重
- 按角色分配权重
- 权重结果计算
八、注意事项
8.1 开发注意事项
-
状态同步:投票结果需实时更新,保证数据一致性
-
性能优化:大量投票需分页加载,避免卡顿
-
动画控制:AnimationController需正确释放
-
用户体验:投票反馈需及时清晰
-
数据持久化:投票数据需本地存储
8.2 常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 投票不更新 | 状态未同步 | 检查setState |
| 选项无法选择 | 已投票限制 | 检查投票状态 |
| 进度条异常 | 数值计算错误 | 检查边界条件 |
| 历史记录缺失 | 数据未保存 | 检查数据持久化 |
| 成员统计错误 | 计算逻辑问题 | 检查遍历逻辑 |
8.3 使用技巧
🗳️ 家庭投票技巧 🗳️
发起投票
- 标题简洁明了,让家人一眼看懂
- 描述详细说明背景和目的
- 选项设置全面,避免遗漏
参与投票
- 认真考虑每个选项
- 可以先讨论再投票
- 尊重投票结果
投票管理
- 设置合理的截止时间
- 及时查看投票进度
- 结果公布后执行决策
九、运行说明
9.1 环境要求
| 环境 | 版本要求 |
|---|---|
| Flutter SDK | >= 3.0.0 |
| Dart SDK | >= 2.17.0 |
| 鸿蒙OS | API 21+ |
| Web浏览器 | Chrome 90+ |
9.2 运行命令
# 查看可用设备
flutter devices
# 运行到Web服务器
flutter run -d web-server -t lib/main_family_voting.dart --web-port 8143
# 运行到鸿蒙设备
flutter run -d 127.0.0.1:5555 lib/main_family_voting.dart
# 代码分析
flutter analyze lib/main_family_voting.dart
十、总结
家庭决策投票器通过首页导航、进行中投票、投票历史、家庭成员四大模块,为家庭提供了一个民主决策的平台。应用支持6种投票分类、6位家庭成员、多种投票规则,让家庭决策更加公平透明。
核心功能涵盖投票创建、投票参与、结果统计、历史记录、成员统计五大模块。投票创建支持自定义标题、描述、分类、选项;投票参与提供直观的选项选择和进度展示;结果统计实时计算百分比和领先项;历史记录保存所有已结束投票;成员统计展示每位成员的投票参与情况。
应用采用 Material Design 3 设计规范,以优雅的紫色为主色调,象征智慧与民主。通过本应用,希望能够帮助家庭以民主方式解决日常决策问题,避免争吵,增进家庭和谐,让每一个家庭成员都有发言权。
家庭决策投票器——民主决策,家庭和谐
更多推荐


所有评论(0)