create project

This commit is contained in:
2026-05-19 11:46:42 +08:00
commit dcdf1f60b1
60 changed files with 9309 additions and 0 deletions

View File

@@ -0,0 +1,541 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../providers/bill_provider.dart';
import '../../providers/family_provider.dart';
import '../../models/bill.dart';
import '../../models/family.dart';
import '../../theme/colors.dart';
import '../../widgets/glass_card.dart';
import '../../widgets/category_icon.dart';
import '../../widgets/stat_card.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
int _selectedTab = 0;
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(billProvider.notifier).loadBills();
ref.read(familyProvider.notifier).loadFamilies();
});
}
String _getCurrentMonth() {
final now = DateTime.now();
final months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
return '${now.year}${months[now.month - 1]}';
}
List<StatCardData> _calculateMonthlyStats(List<Bill> bills) {
final now = DateTime.now();
final currentMonth = now.month;
final currentYear = now.year;
double totalExpense = 0;
double totalIncome = 0;
for (final bill in bills) {
try {
final billDate = DateTime.parse(bill.date);
if (billDate.month == currentMonth && billDate.year == currentYear) {
if (bill.isExpense) {
totalExpense += bill.amount;
} else {
totalIncome += bill.amount;
}
}
} catch (_) {}
}
final balance = totalIncome - totalExpense;
return [
StatCardData(label: '本月支出', value: '¥${totalExpense.toStringAsFixed(0)}', color: AppColors.expense),
StatCardData(label: '本月收入', value: '¥${totalIncome.toStringAsFixed(0)}', color: AppColors.income),
StatCardData(label: '本月结余', value: '¥${balance.toStringAsFixed(0)}', color: AppColors.brand),
];
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final billsAsync = ref.watch(billProvider);
final familiesAsync = ref.watch(familyProvider);
final user = authState.user;
final nickname = user?.nickname ?? '用户';
final firstLetter = nickname.isNotEmpty ? nickname[0] : 'U';
final personalBills = billsAsync.valueOrNull ?? [];
final stats = _calculateMonthlyStats(personalBills);
return Scaffold(
backgroundColor: AppColors.pageBg,
body: Column(
children: [
// OrangeHeader
Container(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16,
left: 20,
right: 20,
bottom: 20,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.brand, AppColors.brandLight],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// User info row
Row(
children: [
// Avatar with first letter
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.5), width: 2),
),
child: Center(
child: Text(
firstLetter,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'你好,$nickname',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
_getCurrentMonth(),
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.85),
),
),
],
),
],
),
const SizedBox(height: 20),
// 3 StatCards
Row(
children: [
Expanded(child: StatCard(label: stats[0].label, value: stats[0].value, color: stats[0].color)),
const SizedBox(width: 10),
Expanded(child: StatCard(label: stats[1].label, value: stats[1].value, color: stats[1].color)),
const SizedBox(width: 10),
Expanded(child: StatCard(label: stats[2].label, value: stats[2].value, color: stats[2].color)),
],
),
],
),
),
// TabSwitcher
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
height: 50,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.8)),
),
child: Row(
children: [
Expanded(
child: _TabButton(
label: '个人账本',
isSelected: _selectedTab == 0,
onTap: () => setState(() => _selectedTab = 0),
),
),
Expanded(
child: _TabButton(
label: '家庭账本',
isSelected: _selectedTab == 1,
onTap: () => setState(() => _selectedTab = 1),
),
),
],
),
),
),
),
),
// Tab content
Expanded(
child: _selectedTab == 0
? _PersonalTab(bills: personalBills)
: _FamilyTab(familiesAsync: familiesAsync, bills: personalBills),
),
],
),
);
}
}
class StatCardData {
final String label;
final String value;
final Color color;
StatCardData({required this.label, required this.value, required this.color});
}
class _TabButton extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _TabButton({
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
gradient: isSelected
? const LinearGradient(colors: [AppColors.brand, AppColors.brandLight])
: null,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? Colors.white : AppColors.textSecondary,
),
),
),
),
);
}
}
class _PersonalTab extends StatelessWidget {
final List<Bill> bills;
const _PersonalTab({required this.bills});
@override
Widget build(BuildContext context) {
if (bills.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.receipt_long_outlined, size: 64, color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16),
Text(
'暂无账单记录',
style: TextStyle(fontSize: 16, color: AppColors.textSecondary.withOpacity(0.7)),
),
],
),
);
}
// Sort by date descending
final sortedBills = List<Bill>.from(bills)
..sort((a, b) => b.date.compareTo(a.date));
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: sortedBills.length,
itemBuilder: (context, index) {
final bill = sortedBills[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GlassCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
CategoryIcon(emoji: bill.emoji, category: bill.category),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
bill.category,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
if (bill.note.isNotEmpty)
Text(
bill.note,
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary.withOpacity(0.8),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${bill.isExpense ? '-' : '+'}¥${bill.amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: bill.isExpense ? AppColors.expense : AppColors.income,
),
),
Text(
bill.time,
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary.withOpacity(0.7),
),
),
],
),
],
),
),
);
},
);
}
}
class _FamilyTab extends StatelessWidget {
final AsyncValue<List<Family>> familiesAsync;
final List<Bill> bills;
const _FamilyTab({required this.familiesAsync, required this.bills});
@override
Widget build(BuildContext context) {
return familiesAsync.when(
loading: () => const Center(child: CircularProgressIndicator(color: AppColors.brand)),
error: (e, _) => Center(child: Text('加载失败: $e')),
data: (families) {
if (families.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.family_restroom_outlined, size: 64, color: AppColors.textSecondary.withOpacity(0.5)),
const SizedBox(height: 16),
Text(
'暂无家庭',
style: TextStyle(fontSize: 16, color: AppColors.textSecondary.withOpacity(0.7)),
),
const SizedBox(height: 8),
Text(
'创建或加入家庭后即可查看',
style: TextStyle(fontSize: 14, color: AppColors.textSecondary.withOpacity(0.5)),
),
],
),
);
}
// Filter family bills
final familyBills = bills.where((b) => b.familyId != null).toList();
final sortedFamilyBills = List<Bill>.from(familyBills)
..sort((a, b) => b.date.compareTo(a.date));
// Calculate member rankings (mock data based on members)
final family = families.first;
final memberStats = <Map<String, dynamic>>[];
for (final member in family.members) {
double total = 0;
for (final bill in familyBills) {
if (bill.userId == member.userId) {
total += bill.amount;
}
}
memberStats.add({'member': member, 'total': total});
}
memberStats.sort((a, b) => (b['total'] as double).compareTo(a['total'] as double));
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
// Family bills section
if (familyBills.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: Text(
'家庭账单',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
),
...sortedFamilyBills.take(5).map((bill) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GlassCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
CategoryIcon(emoji: bill.emoji, category: bill.category),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
bill.category,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
if (bill.note.isNotEmpty)
Text(
bill.note,
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary.withOpacity(0.8),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Text(
'${bill.isExpense ? '-' : '+'}¥${bill.amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: bill.isExpense ? AppColors.expense : AppColors.income,
),
),
],
),
),
)),
const SizedBox(height: 16),
],
// Member rankings section
if (memberStats.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: Text(
'成员排行',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
),
...memberStats.asMap().entries().map((entry) {
final idx = entry.key;
final data = entry.value;
final member = data['member'] as FamilyMember;
final total = data['total'] as double;
final medals = ['🥇', '🥈', '🥉'];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GlassCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: idx < 3 ? AppColors.brand.withOpacity(0.1) : AppColors.textSecondary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Center(
child: Text(
idx < 3 ? medals[idx] : '${idx + 1}',
style: const TextStyle(fontSize: 14),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
member.nickname,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
Text(
'¥${total.toStringAsFixed(0)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.brand,
),
),
],
),
),
);
}),
],
],
);
},
);
}
}