541 lines
19 KiB
Dart
541 lines
19 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
} |