create project
This commit is contained in:
541
frontend/lib/screens/home/home_screen.dart
Normal file
541
frontend/lib/screens/home/home_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user