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,399 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../constants.dart';
import '../../providers/bill_provider.dart';
import '../../theme/colors.dart';
import '../../widgets/number_pad.dart';
import '../../widgets/category_grid.dart';
class AddBillScreen extends ConsumerStatefulWidget {
const AddBillScreen({super.key});
@override
ConsumerState<AddBillScreen> createState() => _AddBillScreenState();
}
class _AddBillScreenState extends ConsumerState<AddBillScreen> {
bool _isExpense = true;
String _amount = '0';
String _category = '餐饮';
String _note = '';
DateTime _selectedDate = DateTime.now();
bool _addToFamily = false;
void _onKeyTap(String key) {
setState(() {
if (key == 'delete') {
if (_amount.length > 1) {
_amount = _amount.substring(0, _amount.length - 1);
} else {
_amount = '0';
}
} else if (key == '.') {
if (!_amount.contains('.')) {
_amount += '.';
}
} else {
if (_amount == '0') {
_amount = key;
} else {
// Limit to 2 decimal places
final parts = _amount.split('.');
if (parts.length == 2 && parts[1].length >= 2) {
return;
}
// Limit total length
if (_amount.length >= 10) {
return;
}
_amount += key;
}
}
});
}
String get _displayAmount {
final value = double.tryParse(_amount) ?? 0;
if (value == value.roundToDouble() && !_amount.contains('.')) {
return value.toInt().toString();
}
return _amount;
}
Future<void> _selectDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: AppColors.brand,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.textPrimary,
),
),
child: child!,
);
},
);
if (picked != null) {
setState(() {
_selectedDate = picked;
});
}
}
Future<void> _saveBill() async {
final amountValue = double.tryParse(_amount);
if (amountValue == null || amountValue <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入有效金额')),
);
return;
}
final data = {
'type': _isExpense ? 'expense' : 'income',
'amount': amountValue,
'category': _category,
'note': _note,
'emoji': AppConstants.categoryEmoji(_category),
'date': '${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}',
'time': '${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}',
'familyId': _addToFamily ? 'default' : null,
};
final success = await ref.read(billProvider.notifier).addBill(data);
if (mounted) {
if (success) {
Navigator.of(context).pop(true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存失败')),
);
}
}
}
@override
Widget build(BuildContext context) {
final categories = _isExpense
? AppConstants.expenseCategories
: AppConstants.incomeCategories;
// Set default category based on type
if (_isExpense && _category == '工资') {
_category = '餐饮';
} else if (!_isExpense && _category == '餐饮') {
_category = '工资';
}
return Scaffold(
backgroundColor: AppColors.pageBg,
body: SafeArea(
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, size: 28),
color: AppColors.textPrimary,
),
const Text(
'添加账单',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
TextButton(
onPressed: _saveBill,
child: const Text(
'保存',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.brand,
),
),
),
],
),
),
// iOS Segment Control
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
height: 36,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.7),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() {
_isExpense = true;
_category = '餐饮';
}),
child: Container(
decoration: BoxDecoration(
color: _isExpense ? AppColors.expense : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'支出',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: _isExpense ? Colors.white : AppColors.textSecondary,
),
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => setState(() {
_isExpense = false;
_category = '工资';
}),
child: Container(
decoration: BoxDecoration(
color: !_isExpense ? AppColors.income : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'收入',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: !_isExpense ? Colors.white : AppColors.textSecondary,
),
),
),
),
),
),
],
),
),
),
// Amount Display
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
const Text(
'¥',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
const SizedBox(width: 4),
Text(
_displayAmount,
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
],
),
),
// Category Grid
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: CategoryGrid(
categories: categories,
selected: _category,
onSelect: (cat) => setState(() => _category = cat),
),
),
const SizedBox(height: 16),
// Note Input
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.edit_note, color: AppColors.textSecondary, size: 20),
const SizedBox(width: 8),
Expanded(
child: TextField(
onChanged: (v) => _note = v,
style: const TextStyle(
fontSize: 15,
color: AppColors.textPrimary,
),
decoration: const InputDecoration(
hintText: '添加备注',
hintStyle: TextStyle(
color: AppColors.textSecondary,
fontSize: 15,
),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
],
),
),
),
const SizedBox(height: 12),
// Date Picker & Family Toggle
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// Date Picker
Expanded(
child: GestureDetector(
onTap: _selectDate,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.calendar_today, color: AppColors.textSecondary, size: 18),
const SizedBox(width: 8),
Text(
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 15,
color: AppColors.textPrimary,
),
),
],
),
),
),
),
const SizedBox(width: 12),
// Family Toggle
GestureDetector(
onTap: () => setState(() => _addToFamily = !_addToFamily),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: _addToFamily
? AppColors.brand.withOpacity(0.1)
: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _addToFamily ? AppColors.brand : Colors.white.withOpacity(0.5),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.family_restroom,
color: _addToFamily ? AppColors.brand : AppColors.textSecondary,
size: 18,
),
const SizedBox(width: 6),
Text(
'家庭账本',
style: TextStyle(
fontSize: 14,
fontWeight: _addToFamily ? FontWeight.w600 : FontWeight.w500,
color: _addToFamily ? AppColors.brand : AppColors.textSecondary,
),
),
],
),
),
),
],
),
),
const Spacer(),
// Number Pad
NumberPad(onKeyTap: _onKeyTap),
const SizedBox(height: 20),
],
),
),
);
}
}

View File

@@ -0,0 +1,204 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../theme/colors.dart';
class BindEmailScreen extends ConsumerStatefulWidget {
const BindEmailScreen({super.key});
@override
ConsumerState<BindEmailScreen> createState() => _BindEmailScreenState();
}
class _BindEmailScreenState extends ConsumerState<BindEmailScreen> {
final _emailController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
String? _validateEmail(String? value) {
if (value == null || value.isEmpty) {
return '请输入邮箱';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value)) {
return '请输入有效的邮箱地址';
}
return null;
}
Future<void> _handleBind() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final authApi = ref.read(authApiProvider);
final result = await authApi.bindEmail(_emailController.text);
if (result['success'] == true) {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('邮箱绑定成功'),
backgroundColor: AppColors.income,
),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['error'] ?? '绑定失败'),
backgroundColor: AppColors.expense,
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('网络错误: $e'),
backgroundColor: AppColors.expense,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFF6B35),
Color(0xFFFF9F5B),
],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'绑定邮箱',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
const Text(
'请输入您的邮箱地址',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
const SizedBox(height: 48),
_buildGlassInput(
child: TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: '请输入邮箱',
hintStyle: TextStyle(color: AppColors.textSecondary),
border: InputBorder.none,
prefixIcon: Icon(Icons.email_outlined, color: AppColors.brand),
),
validator: _validateEmail,
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleBind,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.brand,
disabledBackgroundColor: Colors.white.withOpacity(0.6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.brand),
),
)
: const Text(
'绑定',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
),
),
),
);
}
Widget _buildGlassInput({required Widget child}) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withOpacity(0.8)),
boxShadow: [
BoxShadow(
color: AppColors.brand.withOpacity(0.06),
blurRadius: 32,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: child,
),
),
),
);
}
}

View File

@@ -0,0 +1,215 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../theme/colors.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_phoneController.dispose();
_passwordController.dispose();
super.dispose();
}
String? _validatePhone(String? value) {
if (value == null || value.isEmpty) {
return '请输入手机号';
}
if (value.length != 11) {
return '手机号必须为11位';
}
return null;
}
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
if (value.length < 6) {
return '密码至少6位';
}
return null;
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
final phone = _phoneController.text;
final password = _passwordController.text;
await ref.read(authProvider.notifier).login(phone, password);
final authState = ref.read(authProvider);
if (authState.isLoggedIn && mounted) {
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState.isLoading;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFF6B35),
Color(0xFFFF9F5B),
],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'登录',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
const Text(
'欢迎回来',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
const SizedBox(height: 48),
_buildGlassInput(
child: TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
style: const TextStyle(color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: '请输入手机号',
hintStyle: TextStyle(color: AppColors.textSecondary),
border: InputBorder.none,
prefixIcon: Icon(Icons.phone_android, color: AppColors.brand),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(11),
],
validator: _validatePhone,
),
),
const SizedBox(height: 16),
_buildGlassInput(
child: TextFormField(
controller: _passwordController,
obscureText: true,
style: const TextStyle(color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: '请输入密码',
hintStyle: TextStyle(color: AppColors.textSecondary),
border: InputBorder.none,
prefixIcon: Icon(Icons.lock_outline, color: AppColors.brand),
),
validator: _validatePassword,
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.brand,
disabledBackgroundColor: Colors.white.withOpacity(0.6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
child: isLoading
? const Text(
'登录中...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
)
: const Text(
'登录',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
if (authState.error != null) ...[
const SizedBox(height: 16),
Text(
authState.error!,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
],
],
),
),
),
),
),
),
);
}
Widget _buildGlassInput({required Widget child}) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withOpacity(0.8)),
boxShadow: [
BoxShadow(
color: AppColors.brand.withOpacity(0.06),
blurRadius: 32,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: child,
),
),
),
);
}
}

View File

@@ -0,0 +1,254 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../theme/colors.dart';
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _nicknameController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_nicknameController.dispose();
_phoneController.dispose();
_passwordController.dispose();
super.dispose();
}
String? _validateNickname(String? value) {
if (value == null || value.isEmpty) {
return '请输入昵称';
}
if (value.length > 20) {
return '昵称最多20个字符';
}
return null;
}
String? _validatePhone(String? value) {
if (value == null || value.isEmpty) {
return '请输入手机号';
}
if (value.length != 11) {
return '手机号必须为11位';
}
return null;
}
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
if (value.length < 6) {
return '密码至少6位';
}
return null;
}
Future<void> _handleRegister() async {
if (!_formKey.currentState!.validate()) return;
final phone = _phoneController.text;
final password = _passwordController.text;
final nickname = _nicknameController.text;
await ref.read(authProvider.notifier).register(phone, password, nickname);
final authState = ref.read(authProvider);
if (authState.isLoggedIn && mounted) {
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState.isLoading;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFF6B35),
Color(0xFFFF9F5B),
],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'创建你的账号',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
const Text(
'加入家庭记账,轻松管理财务',
style: TextStyle(
fontSize: 14,
color: Colors.white70,
),
),
const SizedBox(height: 48),
_buildGlassInput(
child: TextFormField(
controller: _nicknameController,
style: const TextStyle(color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: '请输入昵称',
hintStyle: TextStyle(color: AppColors.textSecondary),
border: InputBorder.none,
prefixIcon: Icon(Icons.person_outline, color: AppColors.brand),
),
validator: _validateNickname,
),
),
const SizedBox(height: 16),
_buildGlassInput(
child: TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
style: const TextStyle(color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: '请输入手机号',
hintStyle: TextStyle(color: AppColors.textSecondary),
border: InputBorder.none,
prefixIcon: Icon(Icons.phone_android, color: AppColors.brand),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(11),
],
validator: _validatePhone,
),
),
const SizedBox(height: 16),
_buildGlassInput(
child: TextFormField(
controller: _passwordController,
obscureText: true,
style: const TextStyle(color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: '请输入密码',
hintStyle: TextStyle(color: AppColors.textSecondary),
border: InputBorder.none,
prefixIcon: Icon(Icons.lock_outline, color: AppColors.brand),
),
validator: _validatePassword,
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.brand,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.brand.withOpacity(0.6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
child: isLoading
? const Text(
'注册中...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
)
: const Text(
'注册',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
if (authState.error != null) ...[
const SizedBox(height: 16),
Text(
authState.error!,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
],
const SizedBox(height: 24),
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Text(
'已有账号?立即登录',
style: TextStyle(
fontSize: 14,
color: Colors.white,
decoration: TextDecoration.underline,
),
),
),
],
),
),
),
),
),
),
);
}
Widget _buildGlassInput({required Widget child}) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withOpacity(0.8)),
boxShadow: [
BoxShadow(
color: AppColors.brand.withOpacity(0.06),
blurRadius: 32,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: child,
),
),
),
);
}
}

View File

@@ -0,0 +1,389 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../theme/colors.dart';
import 'bind_email_screen.dart';
class UserProfileScreen extends ConsumerStatefulWidget {
const UserProfileScreen({super.key});
@override
ConsumerState<UserProfileScreen> createState() => _UserProfileScreenState();
}
class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final user = authState.user;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFF6B35),
Color(0xFFFF9F5B),
],
),
),
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 40),
// Header
const Text(
'个人资料',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 40),
// Avatar and User Info
_buildUserInfoCard(user),
const SizedBox(height: 24),
// Menu Items
Expanded(
child: _buildMenuItems(user),
),
],
),
),
),
);
}
Widget _buildUserInfoCard(user) {
final nickname = user?.nickname ?? '用户';
final phone = user?.phone ?? '';
final firstLetter = nickname.isNotEmpty ? nickname[0].toUpperCase() : '?';
final joinDate = user?.joinDate ?? '';
final email = user?.email;
final hasEmail = email != null && email.isNotEmpty;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Avatar with orange border
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: AppColors.brand,
width: 3,
),
color: Colors.white,
),
child: Center(
child: Text(
firstLetter,
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: AppColors.brand,
),
),
),
),
const SizedBox(height: 16),
// Nickname
Text(
nickname,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
// Phone
Text(
phone,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 20),
// Info Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildInfoItem('加入时间', _formatDate(joinDate)),
Container(
width: 1,
height: 30,
color: AppColors.textSecondary.withOpacity(0.3),
),
_buildInfoItem(
'邮箱',
hasEmail ? '已绑定' : '未绑定',
valueColor: hasEmail ? AppColors.income : AppColors.expense,
),
Container(
width: 1,
height: 30,
color: AppColors.textSecondary.withOpacity(0.3),
),
_buildInfoItem('活跃家庭', user?.activeFamilyId ?? ''),
],
),
],
),
),
),
),
);
}
Widget _buildInfoItem(String label, String value, {Color? valueColor}) {
return Column(
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: valueColor ?? AppColors.textPrimary,
),
),
],
);
}
Widget _buildMenuItems(user) {
final hasEmail = user?.email != null && user!.email.isNotEmpty;
final familyIds = user?.familyIds ?? [];
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Column(
children: [
// Bind Email (if not bound)
_buildMenuItem(
icon: Icons.email_outlined,
title: '绑定邮箱',
subtitle: hasEmail ? user.email : '未绑定邮箱',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const BindEmailScreen(),
),
);
},
),
_buildDivider(),
// Switch Family (if has families)
_buildMenuItem(
icon: Icons.family_restroom_outlined,
title: '切换家庭',
subtitle: familyIds.isNotEmpty ? '${familyIds.length} 个家庭' : '暂无家庭',
onTap: familyIds.isNotEmpty
? () {
_showFamilySwitchDialog();
}
: null,
),
_buildDivider(),
// Logout
_buildMenuItem(
icon: Icons.logout_outlined,
title: '退出登录',
subtitle: '安全退出当前账号',
onTap: () {
_showLogoutDialog();
},
isLast: true,
),
],
),
),
),
);
}
Widget _buildMenuItem({
required IconData icon,
required String title,
required String subtitle,
required VoidCallback? onTap,
bool isLast = false,
}) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.brand.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
color: AppColors.brand,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: AppColors.textSecondary.withOpacity(0.5),
),
],
),
),
);
}
Widget _buildDivider() {
return Container(
margin: const EdgeInsets.only(left: 76),
height: 1,
color: AppColors.textSecondary.withOpacity(0.2),
);
}
String _formatDate(String dateStr) {
if (dateStr.isEmpty) return '未知';
try {
// Assuming dateStr is in format like "2024-01-15" or ISO format
final date = DateTime.parse(dateStr);
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
} catch (e) {
return dateStr;
}
}
void _showFamilySwitchDialog() {
final user = ref.read(authProvider).user;
final familyIds = user?.familyIds ?? [];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('切换家庭'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: familyIds.map((familyId) {
final isActive = familyId == user?.activeFamilyId;
return ListTile(
title: Text(familyId),
trailing: isActive
? const Icon(Icons.check, color: AppColors.brand)
: null,
onTap: () {
// TODO: Implement family switch
Navigator.pop(context);
},
);
}).toList(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
],
),
);
}
void _showLogoutDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定要退出当前账号吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await ref.read(authProvider.notifier).logout();
if (mounted) {
// Navigate to login or pop to root
Navigator.of(context).popUntil((route) => route.isFirst);
}
},
child: const Text(
'确定',
style: TextStyle(color: AppColors.expense),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,425 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/family_provider.dart';
import '../../providers/auth_provider.dart';
import '../../theme/colors.dart';
import '../../models/family.dart';
import '../../models/user.dart';
class FamilyManagementScreen extends ConsumerStatefulWidget {
const FamilyManagementScreen({super.key});
@override
ConsumerState<FamilyManagementScreen> createState() => _FamilyManagementScreenState();
}
class _FamilyManagementScreenState extends ConsumerState<FamilyManagementScreen> {
@override
void initState() {
super.initState();
// Load families when screen opens
Future.microtask(() => ref.read(familyProvider.notifier).loadFamilies());
}
@override
Widget build(BuildContext context) {
final familiesAsync = ref.watch(familyProvider);
final authState = ref.watch(authProvider);
final activeFamilyId = authState.user?.activeFamilyId;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFF6B35),
Color(0xFFFF9F5B),
],
),
),
child: SafeArea(
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
const Expanded(
child: Text(
'家庭管理',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 48),
],
),
),
const SizedBox(height: 20),
// Family List
Expanded(
child: familiesAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
error: (err, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.white, size: 48),
const SizedBox(height: 16),
Text(
'加载失败: $err',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => ref.read(familyProvider.notifier).loadFamilies(),
child: const Text('重试', style: TextStyle(color: Colors.white)),
),
],
),
),
data: (families) => _buildFamilyList(families, activeFamilyId),
),
),
// Action Buttons
_buildActionButtons(),
const SizedBox(height: 24),
],
),
),
),
);
}
Widget _buildFamilyList(List<Family> families, String? activeFamilyId) {
if (families.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_outlined,
size: 64,
color: Colors.white.withOpacity(0.7),
),
const SizedBox(height: 16),
Text(
'暂无家庭',
style: TextStyle(
fontSize: 18,
color: Colors.white.withOpacity(0.8),
),
),
const SizedBox(height: 8),
Text(
'创建或加入一个家庭开始使用',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.6),
),
),
],
),
);
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: families.length,
separatorBuilder: (_, __) => Container(
margin: const EdgeInsets.only(left: 60),
height: 1,
color: AppColors.textSecondary.withOpacity(0.2),
),
itemBuilder: (context, index) {
final family = families[index];
final isActive = family.id == activeFamilyId;
return _buildFamilyItem(family, isActive);
},
),
),
),
);
}
Widget _buildFamilyItem(Family family, bool isActive) {
return InkWell(
onTap: () => _switchActiveFamily(family.id),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.brand.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.home,
color: AppColors.brand,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
family.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
if (isActive) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.brand,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'当前',
style: TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
const SizedBox(height: 2),
Text(
'${family.members.length} 位成员',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: AppColors.textSecondary.withOpacity(0.5),
),
],
),
),
);
}
Widget _buildActionButtons() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Expanded(
child: _buildActionButton(
icon: Icons.add,
label: '创建家庭',
onTap: _showCreateFamilyDialog,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildActionButton(
icon: Icons.link,
label: '加入家庭',
onTap: _showJoinFamilyDialog,
),
),
],
),
);
}
Widget _buildActionButton({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
),
),
);
}
void _showCreateFamilyDialog() {
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: () async {
final name = controller.text.trim();
if (name.isEmpty) return;
Navigator.pop(context);
await _createFamily(name);
},
child: const Text('创建'),
),
],
),
);
}
void _showJoinFamilyDialog() {
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: () async {
final code = controller.text.trim();
if (code.isEmpty) return;
Navigator.pop(context);
await _joinFamily(code);
},
child: const Text('加入'),
),
],
),
);
}
Future<void> _createFamily(String name) async {
final family = await ref.read(familyProvider.notifier).createFamily(name);
if (family != null && mounted) {
// Reload families to show the new one
await ref.read(familyProvider.notifier).loadFamilies();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('家庭 "${family.name}" 创建成功')),
);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('创建失败,请重试')),
);
}
}
Future<void> _joinFamily(String inviteCode) async {
final family = await ref.read(familyProvider.notifier).joinFamily(inviteCode);
if (family != null && mounted) {
// Reload families to show the new one
await ref.read(familyProvider.notifier).loadFamilies();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('成功加入家庭 "${family.name}"')),
);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('加入失败,请检查邀请码')),
);
}
}
Future<void> _switchActiveFamily(String familyId) async {
// Update the user's activeFamilyId through auth provider
final currentUser = ref.read(authProvider).user;
if (currentUser != null) {
final updatedUser = User(
id: currentUser.id,
phone: currentUser.phone,
nickname: currentUser.nickname,
email: currentUser.email,
familyIds: currentUser.familyIds,
activeFamilyId: familyId,
joinDate: currentUser.joinDate,
createdAt: currentUser.createdAt,
);
ref.read(authProvider.notifier).state = ref.read(authProvider).copyWith(
user: updatedUser,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已切换家庭')),
);
}
}
}

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,
),
),
],
),
),
);
}),
],
],
);
},
);
}
}

View File

@@ -0,0 +1,435 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../providers/family_provider.dart';
import '../../theme/colors.dart';
import '../../widgets/glass_card.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final isLoggedIn = authState.isLoggedIn;
final user = authState.user;
return Scaffold(
backgroundColor: AppColors.pageBg,
body: isLoggedIn && user != null
? _LoggedInContent(user: user)
: const _LoggedOutContent(),
);
}
}
class _LoggedOutContent extends StatelessWidget {
const _LoggedOutContent();
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
const Spacer(),
// Glass card with login prompt
GlassCard(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.brand, AppColors.brandLight],
),
shape: BoxShape.circle,
),
child: const Icon(
Icons.person_outline,
size: 40,
color: Colors.white,
),
),
const SizedBox(height: 20),
const Text(
'登录后可同步数据',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'管理家庭账本',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary.withOpacity(0.8),
),
),
const SizedBox(height: 24),
// Login button
GestureDetector(
onTap: () {
Navigator.pushNamed(context, '/login');
},
child: Container(
width: double.infinity,
height: 50,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.brand, AppColors.brandLight],
),
borderRadius: BorderRadius.circular(25),
),
child: const Center(
child: Text(
'立即登录',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
],
),
),
const Spacer(flex: 2),
],
),
),
);
}
}
class _LoggedInContent extends ConsumerWidget {
final dynamic user;
const _LoggedInContent({required this.user});
@override
Widget build(BuildContext context, WidgetRef ref) {
final familiesAsync = ref.watch(familyProvider);
final nickname = user.nickname ?? '用户';
final firstLetter = nickname.isNotEmpty ? nickname[0].toUpperCase() : 'U';
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
// User card with orange gradient avatar
GlassCard(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Row(
children: [
// Orange gradient circular avatar with first letter
Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.brand, AppColors.brandLight],
),
shape: BoxShape.circle,
),
child: Center(
child: Text(
firstLetter,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nickname,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
'ID: ${user.id ?? ""}',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary.withOpacity(0.8),
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Email status and family info
Row(
children: [
Icon(
user.email != null && user.email!.isNotEmpty
? Icons.check_circle
: Icons.warning_amber_outlined,
size: 16,
color: user.email != null && user.email!.isNotEmpty
? AppColors.income
: AppColors.expense,
),
const SizedBox(width: 6),
Text(
user.email != null && user.email!.isNotEmpty
? '邮箱已绑定'
: '未绑定邮箱',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary.withOpacity(0.8),
),
),
const Spacer(),
familiesAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (families) {
if (families.isEmpty) {
return const SizedBox.shrink();
}
return Row(
children: [
const Icon(
Icons.home_outlined,
size: 16,
color: AppColors.brand,
),
const SizedBox(width: 6),
Text(
families.first.name,
style: const TextStyle(
fontSize: 13,
color: AppColors.brand,
),
),
],
);
},
),
],
),
],
),
),
const SizedBox(height: 24),
// Settings groups with glassmorphism
_SettingsGroup(
title: '账本管理',
items: const [
_SettingsItem(icon: '🏠', label: '我的家庭', route: '/family'),
_SettingsItem(icon: '📒', label: '账本管理', route: '/books'),
_SettingsItem(icon: '💰', label: '预算设置', route: '/budget'),
_SettingsItem(icon: '🏷️', label: '分类管理', route: '/categories'),
],
),
const SizedBox(height: 16),
_SettingsGroup(
title: '系统设置',
items: const [
_SettingsItem(icon: '🤖', label: 'AI设置', route: '/ai-settings'),
_SettingsItem(icon: '📤', label: '数据导出', route: '/export'),
_SettingsItem(icon: '🔔', label: '提醒设置', route: '/notifications'),
],
),
const SizedBox(height: 16),
_SettingsGroup(
title: '关于',
items: const [
_SettingsItem(icon: '', label: '关于', route: '/about'),
_SettingsItem(icon: '💬', label: '帮助与反馈', route: '/feedback'),
],
),
const SizedBox(height: 24),
// Logout button with red border
GestureDetector(
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认退出'),
content: const Text('确定要退出登录吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'退出',
style: TextStyle(color: AppColors.expense),
),
),
],
),
);
if (confirmed == true) {
await ref.read(authProvider.notifier).logout();
}
},
child: Container(
width: double.infinity,
height: 50,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(25),
border: Border.all(color: AppColors.expense, width: 1.5),
),
child: const Center(
child: Text(
'退出登录',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.expense,
),
),
),
),
),
const SizedBox(height: 20),
],
),
),
);
}
}
class _SettingsGroup extends StatelessWidget {
final String title;
final List<_SettingsItem> items;
const _SettingsGroup({
required this.title,
required this.items,
});
@override
Widget build(BuildContext context) {
return GlassCard(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.brand,
),
),
),
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final isLast = index == items.length - 1;
return Column(
children: [
_SettingsTile(item: item),
if (!isLast)
Divider(
height: 1,
indent: 56,
endIndent: 16,
color: AppColors.textSecondary.withOpacity(0.1),
),
],
);
}),
const SizedBox(height: 8),
],
),
);
}
}
class _SettingsItem {
final String icon;
final String label;
final String route;
const _SettingsItem({
required this.icon,
required this.label,
required this.route,
});
}
class _SettingsTile extends StatelessWidget {
final _SettingsItem item;
const _SettingsTile({required this.item});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.pushNamed(context, item.route);
},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Text(
item.icon,
style: const TextStyle(fontSize: 20),
),
const SizedBox(width: 12),
Expanded(
child: Text(
item.label,
style: const TextStyle(
fontSize: 15,
color: AppColors.textPrimary,
),
),
),
Icon(
Icons.chevron_right,
size: 20,
color: AppColors.textSecondary.withOpacity(0.5),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,696 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../providers/bill_provider.dart';
import '../../widgets/stat_card.dart';
import '../../constants.dart';
import '../../theme/colors.dart';
class StatisticsScreen extends ConsumerStatefulWidget {
const StatisticsScreen({super.key});
@override
ConsumerState<StatisticsScreen> createState() => _StatisticsScreenState();
}
class _StatisticsScreenState extends ConsumerState<StatisticsScreen> {
int _monthOffset = 0;
DateTime get _selectedDate {
final now = DateTime.now();
return DateTime(now.year, now.month + _monthOffset, 1);
}
String get _displayMonth {
return '${_selectedDate.year}${_selectedDate.month.toString().padLeft(2, '0')}';
}
List<Bill> _filterBillsByMonth(List<Bill> bills) {
return bills.where((bill) {
final billDate = DateTime.tryParse(bill.date);
if (billDate == null) return false;
return billDate.year == _selectedDate.year && billDate.month == _selectedDate.month;
}).toList();
}
Map<String, double> _getCategoryTotals(List<Bill> bills, bool isExpense) {
final Map<String, double> totals = {};
for (final bill in bills) {
if (isExpense ? bill.isExpense : bill.isIncome) {
totals[bill.category] = (totals[bill.category] ?? 0) + bill.amount;
}
}
return totals;
}
Map<int, double> _getDailyTotals(List<Bill> bills) {
final Map<int, double> totals = {};
for (final bill in bills) {
if (bill.isExpense) {
final billDate = DateTime.tryParse(bill.date);
if (billDate != null) {
totals[billDate.day] = (totals[billDate.day] ?? 0) + bill.amount;
}
}
}
return totals;
}
List<Map<String, dynamic>> _getMonthlyComparison(List<Bill> bills) {
final now = DateTime.now();
final List<Map<String, dynamic>> result = [];
for (int i = 5; i >= 0; i--) {
final date = DateTime(now.year, now.month - i, 1);
final monthBills = bills.where((bill) {
final billDate = DateTime.tryParse(bill.date);
return billDate != null && billDate.year == date.year && billDate.month == date.month;
}).toList();
double income = 0;
double expense = 0;
for (final bill in monthBills) {
if (bill.isIncome) income += bill.amount;
else expense += bill.amount;
}
result.add({
'month': '${date.year}-${date.month.toString().padLeft(2, '0')}',
'income': income,
'expense': expense,
});
}
return result;
}
@override
Widget build(BuildContext context) {
final billsAsync = ref.watch(billProvider);
return Scaffold(
backgroundColor: AppColors.pageBg,
body: billsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (bills) {
final monthBills = _filterBillsByMonth(bills);
double totalIncome = 0;
double totalExpense = 0;
for (final bill in monthBills) {
if (bill.isIncome) totalIncome += bill.amount;
else totalExpense += bill.amount;
}
final balance = totalIncome - totalExpense;
final expenseCategories = _getCategoryTotals(monthBills, true);
final incomeCategories = _getCategoryTotals(monthBills, false);
final dailyTotals = _getDailyTotals(monthBills);
final monthlyComparison = _getMonthlyComparison(bills);
return CustomScrollView(
slivers: [
_buildHeader(),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildMonthSelector(),
const SizedBox(height: 16),
_buildStatCards(totalIncome, totalExpense, balance),
const SizedBox(height: 20),
_buildPieChart(totalExpense, balance),
const SizedBox(height: 20),
_buildCategoryRanking(expenseCategories, true),
const SizedBox(height: 20),
_buildCategoryRanking(incomeCategories, false),
const SizedBox(height: 20),
_buildDailyBarChart(dailyTotals),
const SizedBox(height: 20),
_buildMonthlyComparison(monthlyComparison),
const SizedBox(height: 40),
],
),
),
),
],
);
},
),
);
}
Widget _buildHeader() {
return SliverAppBar(
expandedHeight: 100,
floating: false,
pinned: true,
backgroundColor: AppColors.brand,
flexibleSpace: FlexibleSpaceBar(
title: const Text(
'统计',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.brand, AppColors.brandLight],
),
),
),
),
);
}
Widget _buildMonthSelector() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
color: AppColors.cardBg,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.8)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, color: AppColors.textPrimary),
onPressed: () => setState(() => _monthOffset--),
),
Text(
_displayMonth,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
IconButton(
icon: const Icon(Icons.chevron_right, color: AppColors.textPrimary),
onPressed: _monthOffset < 0 ? () => setState(() => _monthOffset++) : null,
),
],
),
);
}
Widget _buildStatCards(double income, double expense, double balance) {
return Row(
children: [
Expanded(
child: StatCard(
label: '收入',
value: '¥${income.toStringAsFixed(2)}',
color: AppColors.income,
),
),
const SizedBox(width: 8),
Expanded(
child: StatCard(
label: '支出',
value: '¥${expense.toStringAsFixed(2)}',
color: AppColors.expense,
),
),
const SizedBox(width: 8),
Expanded(
child: StatCard(
label: '结余',
value: '¥${balance.toStringAsFixed(2)}',
color: balance >= 0 ? AppColors.income : AppColors.expense,
),
),
],
);
}
Widget _buildPieChart(double expense, double balance) {
final total = expense + balance;
final usedPercent = total > 0 ? (expense / total * 100) : 0.0;
final remainingPercent = total > 0 ? (balance / total * 100) : 0.0;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.cardBg,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.8)),
boxShadow: [
BoxShadow(
color: AppColors.brand.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'预算使用',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: total > 0
? PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: [
PieChartSectionData(
value: expense,
color: AppColors.expense,
title: '${usedPercent.toStringAsFixed(1)}%',
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
radius: 50,
),
PieChartSectionData(
value: balance,
color: AppColors.income,
title: '${remainingPercent.toStringAsFixed(1)}%',
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
radius: 50,
),
],
),
)
: const Center(
child: Text(
'暂无数据',
style: TextStyle(color: AppColors.textSecondary),
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegendItem('已支出', AppColors.expense),
const SizedBox(width: 24),
_buildLegendItem('剩余', AppColors.income),
],
),
],
),
),
),
);
}
Widget _buildLegendItem(String label, Color color) {
return Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
);
}
Widget _buildCategoryRanking(Map<String, double> categories, bool isExpense) {
if (categories.isEmpty) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.cardBg,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.8)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isExpense ? '支出分类' : '收入分类',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 16),
const Center(
child: Text(
'暂无数据',
style: TextStyle(color: AppColors.textSecondary),
),
),
],
),
);
}
final sortedEntries = categories.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final maxAmount = sortedEntries.first.value;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.cardBg,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.8)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isExpense ? '支出分类' : '收入分类',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 16),
...sortedEntries.take(6).map((entry) {
final emoji = AppConstants.categoryEmoji(entry.key);
final progress = maxAmount > 0 ? entry.value / maxAmount : 0.0;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Expanded(
child: Text(
entry.key,
style: const TextStyle(
fontSize: 13,
color: AppColors.textPrimary,
),
),
),
Text(
'¥${entry.value.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isExpense ? AppColors.expense : AppColors.income,
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation(
isExpense ? AppColors.expense : AppColors.income,
),
minHeight: 6,
),
),
],
),
);
}),
],
),
),
),
);
}
Widget _buildDailyBarChart(Map<int, double> dailyTotals) {
final spots = <FlSpot>[];
for (int day = 1; day <= 31; day++) {
spots.add(FlSpot(day.toDouble(), dailyTotals[day] ?? 0));
}
final maxY = dailyTotals.values.isEmpty
? 100.0
: dailyTotals.values.reduce((a, b) => a > b ? a : b) * 1.2;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.cardBg,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.8)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'日消费趋势',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxY,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'¥${rod.toY.toStringAsFixed(2)}',
const TextStyle(color: Colors.white, fontSize: 12),
);
},
),
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value.toInt() % 5 == 0) {
return Text(
'${value.toInt()}',
style: const TextStyle(
fontSize: 10,
color: AppColors.textSecondary,
),
);
}
return const SizedBox.shrink();
},
reservedSize: 20,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return Text(
'¥${value.toInt()}',
style: const TextStyle(
fontSize: 10,
color: AppColors.textSecondary,
),
);
},
reservedSize: 40,
),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: maxY / 4,
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey.shade200,
strokeWidth: 1,
);
},
),
borderData: FlBorderData(show: false),
barGroups: List.generate(31, (index) {
final day = index + 1;
final value = dailyTotals[day] ?? 0;
return BarChartGroupData(
x: day,
barRods: [
BarChartRodData(
toY: value,
color: AppColors.brand,
width: 8,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
],
);
}),
),
),
),
],
),
),
),
);
}
Widget _buildMonthlyComparison(List<Map<String, dynamic>> months) {
double maxValue = 0;
for (final month in months) {
final income = month['income'] as double;
final expense = month['expense'] as double;
if (income > maxValue) maxValue = income;
if (expense > maxValue) maxValue = expense;
}
if (maxValue == 0) maxValue = 1000;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.cardBg,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.8)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'月度对比',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 16),
...months.map((month) {
final monthStr = month['month'] as String;
final income = month['income'] as double;
final expense = month['expense'] as double;
final monthLabel = monthStr.substring(5);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$monthLabel月',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
Row(
children: [
Text(
'收¥${income.toStringAsFixed(0)}',
style: const TextStyle(
fontSize: 11,
color: AppColors.income,
),
),
const SizedBox(width: 8),
Text(
'支¥${expense.toStringAsFixed(0)}',
style: const TextStyle(
fontSize: 11,
color: AppColors.expense,
),
),
],
),
],
),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: maxValue > 0 ? income / maxValue : 0,
backgroundColor: Colors.grey.shade200,
valueColor: const AlwaysStoppedAnimation(AppColors.income),
minHeight: 8,
),
),
),
const SizedBox(width: 8),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: maxValue > 0 ? expense / maxValue : 0,
backgroundColor: Colors.grey.shade200,
valueColor: const AlwaysStoppedAnimation(AppColors.expense),
minHeight: 8,
),
),
),
],
),
],
),
);
}),
],
),
),
),
);
}
}