create project
This commit is contained in:
45
frontend/lib/api/api_client.dart
Normal file
45
frontend/lib/api/api_client.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
final _storage = const FlutterSecureStorage();
|
||||
static const _tokenKey = 'auth_token';
|
||||
|
||||
ApiClient({String? baseUrl}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl ?? 'http://localhost:3000/api',
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
));
|
||||
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final token = await _storage.read(key: _tokenKey);
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? params}) async {
|
||||
final res = await _dio.get(path, queryParameters: params);
|
||||
return res.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> post(String path, {dynamic data}) async {
|
||||
final res = await _dio.post(path, data: data);
|
||||
return res.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> put(String path, {dynamic data}) async {
|
||||
final res = await _dio.put(path, data: data);
|
||||
return res.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<void> saveToken(String token) => _storage.write(key: _tokenKey, value: token);
|
||||
Future<String?> getToken() => _storage.read(key: _tokenKey);
|
||||
Future<void> clearToken() => _storage.delete(key: _tokenKey);
|
||||
}
|
||||
30
frontend/lib/api/auth_api.dart
Normal file
30
frontend/lib/api/auth_api.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'api_client.dart';
|
||||
|
||||
class AuthApi {
|
||||
final ApiClient _client;
|
||||
AuthApi(this._client);
|
||||
|
||||
Future<Map<String, dynamic>> login(String phone, String password) {
|
||||
return _client.post('/auth/login', data: {'phone': phone, 'password': password});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> register(String phone, String password, String nickname) {
|
||||
return _client.post('/auth/register', data: {'phone': phone, 'password': password, 'nickname': nickname});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> logout() {
|
||||
return _client.post('/auth/logout');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getProfile() {
|
||||
return _client.get('/user/profile');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> bindEmail(String email) {
|
||||
return _client.put('/user/email', data: {'email': email});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> switchActiveFamily(String? familyId) {
|
||||
return _client.put('/user/active-family', data: {'familyId': familyId});
|
||||
}
|
||||
}
|
||||
18
frontend/lib/api/bill_api.dart
Normal file
18
frontend/lib/api/bill_api.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'api_client.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class BillApi {
|
||||
final ApiClient _client;
|
||||
BillApi(this._client);
|
||||
|
||||
Future<Map<String, dynamic>> getBills({String? userId, String? familyId}) {
|
||||
final params = <String, dynamic>{};
|
||||
if (userId != null) params['userId'] = userId;
|
||||
if (familyId != null) params['familyId'] = familyId;
|
||||
return _client.get('/bills', params: params);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createBill(Map<String, dynamic> data) {
|
||||
return _client.post('/bills', data: data);
|
||||
}
|
||||
}
|
||||
26
frontend/lib/api/family_api.dart
Normal file
26
frontend/lib/api/family_api.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'api_client.dart';
|
||||
|
||||
class FamilyApi {
|
||||
final ApiClient _client;
|
||||
FamilyApi(this._client);
|
||||
|
||||
Future<Map<String, dynamic>> getFamilies() {
|
||||
return _client.get('/families');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getFamily(String id) {
|
||||
return _client.get('/families/$id');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createFamily(String name) {
|
||||
return _client.post('/families', data: {'name': name});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> joinFamily(String inviteCode) {
|
||||
return _client.post('/families/join', data: {'inviteCode': inviteCode});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> leaveFamily(String familyId) {
|
||||
return _client.post('/families/leave', data: {'familyId': familyId});
|
||||
}
|
||||
}
|
||||
128
frontend/lib/app.dart
Normal file
128
frontend/lib/app.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'screens/home/home_screen.dart';
|
||||
import 'screens/auth/login_screen.dart';
|
||||
import 'screens/auth/register_screen.dart';
|
||||
import 'screens/profile/profile_screen.dart';
|
||||
import 'screens/statistics/statistics_screen.dart';
|
||||
import 'screens/add_bill/add_bill_screen.dart';
|
||||
import 'screens/auth/user_profile_screen.dart';
|
||||
import 'screens/auth/bind_email_screen.dart';
|
||||
import 'screens/family/family_management_screen.dart';
|
||||
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
const AppShell({super.key});
|
||||
@override
|
||||
ConsumerState<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends ConsumerState<AppShell> {
|
||||
int _currentTab = 0;
|
||||
bool _showAddBill = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(authProvider.notifier).restoreSession();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
Widget body;
|
||||
if (_showAddBill) {
|
||||
return const AddBillScreen();
|
||||
}
|
||||
|
||||
switch (_currentTab) {
|
||||
case 0:
|
||||
body = const HomeScreen();
|
||||
break;
|
||||
case 1:
|
||||
body = const StatisticsScreen();
|
||||
break;
|
||||
case 2:
|
||||
body = const ProfileScreen();
|
||||
break;
|
||||
default:
|
||||
body = const HomeScreen();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: body,
|
||||
bottomNavigationBar: Container(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardBg,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.8)),
|
||||
boxShadow: [BoxShadow(color: AppColors.brand.withOpacity(0.08), blurRadius: 32, offset: const Offset(0, 8))],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: _currentTab,
|
||||
onTap: (i) => setState(() => _currentTab = i),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
selectedItemColor: AppColors.brand,
|
||||
unselectedItemColor: AppColors.textSecondary,
|
||||
items: const [
|
||||
BottomNavigationBarItem(icon: Icon(Icons.home_outlined, size: 22), activeIcon: Icon(Icons.home, size: 22), label: '首页'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.bar_chart_outlined, size: 22), activeIcon: Icon(Icons.bar_chart, size: 22), label: '统计'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.person_outline, size: 22), activeIcon: Icon(Icons.person, size: 22), label: '我的'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (!authState.isLoggedIn && mounted) {
|
||||
_navigateToLogin();
|
||||
return;
|
||||
}
|
||||
setState(() => _showAddBill = true);
|
||||
},
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [Color(0xFFFF6B35), Color(0xFFFF9F5B)]),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: AppColors.brand.withOpacity(0.3), blurRadius: 12, offset: const Offset(0, 4))],
|
||||
),
|
||||
child: const Icon(Icons.add, color: Colors.white, size: 24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToLogin() {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const LoginScreen()));
|
||||
}
|
||||
}
|
||||
|
||||
// Colors used in this file
|
||||
class AppColors {
|
||||
static const brand = Color(0xFFFF6B35);
|
||||
static const brandLight = Color(0xFFFF9F5B);
|
||||
static const cardBg = Color(0xA6FFFFFF);
|
||||
static const textPrimary = Color(0xFF1C1C1E);
|
||||
static const textSecondary = Color(0xFF8E8E93);
|
||||
}
|
||||
28
frontend/lib/constants.dart
Normal file
28
frontend/lib/constants.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
class AppConstants {
|
||||
static const expenseCategories = [
|
||||
{'emoji': '🍜', 'name': '餐饮'},
|
||||
{'emoji': '🚗', 'name': '交通'},
|
||||
{'emoji': '🛒', 'name': '购物'},
|
||||
{'emoji': '🏠', 'name': '居住'},
|
||||
{'emoji': '💊', 'name': '医疗'},
|
||||
{'emoji': '🎮', 'name': '娱乐'},
|
||||
{'emoji': '📚', 'name': '教育'},
|
||||
{'emoji': '📦', 'name': '其他'},
|
||||
];
|
||||
static const incomeCategories = [
|
||||
{'emoji': '💰', 'name': '工资'},
|
||||
{'emoji': '📈', 'name': '理财'},
|
||||
{'emoji': '🎓', 'name': '兼职'},
|
||||
{'emoji': '🥇', 'name': '红包'},
|
||||
{'emoji': '🏆', 'name': '奖金'},
|
||||
{'emoji': '💲', 'name': '其他'},
|
||||
];
|
||||
static String categoryEmoji(String category) {
|
||||
for (final list in [expenseCategories, incomeCategories]) {
|
||||
for (final item in list) {
|
||||
if (item['name'] == category) return item['emoji']!;
|
||||
}
|
||||
}
|
||||
return '📝';
|
||||
}
|
||||
}
|
||||
23
frontend/lib/main.dart
Normal file
23
frontend/lib/main.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'app.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const ProviderScope(child: FamilyAccountingApp()));
|
||||
}
|
||||
|
||||
class FamilyAccountingApp extends StatelessWidget {
|
||||
const FamilyAccountingApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: '家庭记账',
|
||||
theme: AppTheme.light,
|
||||
home: const AppShell(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
48
frontend/lib/models/bill.dart
Normal file
48
frontend/lib/models/bill.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
enum BillType { expense, income }
|
||||
|
||||
class Bill {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String? familyId;
|
||||
final BillType type;
|
||||
final double amount;
|
||||
final String category;
|
||||
final String note;
|
||||
final String emoji;
|
||||
final String time;
|
||||
final String date;
|
||||
final String createdAt;
|
||||
|
||||
const Bill({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
this.familyId,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
required this.category,
|
||||
this.note = '',
|
||||
this.emoji = '📝',
|
||||
required this.time,
|
||||
required this.date,
|
||||
this.createdAt = '',
|
||||
});
|
||||
|
||||
bool get isExpense => type == BillType.expense;
|
||||
bool get isIncome => type == BillType.income;
|
||||
|
||||
factory Bill.fromJson(Map<String, dynamic> json) {
|
||||
return Bill(
|
||||
id: json['id'] as String? ?? '',
|
||||
userId: json['userId'] as String? ?? '',
|
||||
familyId: json['familyId'] as String?,
|
||||
type: json['type'] as String? == 'income' ? BillType.income : BillType.expense,
|
||||
amount: (json['amount'] as num?)?.toDouble() ?? 0,
|
||||
category: json['category'] as String? ?? '',
|
||||
note: json['note'] as String? ?? '',
|
||||
emoji: json['emoji'] as String? ?? '📝',
|
||||
time: json['time'] as String? ?? '00:00',
|
||||
date: json['date'] as String? ?? '',
|
||||
createdAt: json['createdAt'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
53
frontend/lib/models/family.dart
Normal file
53
frontend/lib/models/family.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
class FamilyMember {
|
||||
final String userId;
|
||||
final String nickname;
|
||||
final String role;
|
||||
final String joinedAt;
|
||||
|
||||
const FamilyMember({
|
||||
required this.userId,
|
||||
required this.nickname,
|
||||
this.role = 'member',
|
||||
this.joinedAt = '',
|
||||
});
|
||||
|
||||
factory FamilyMember.fromJson(Map<String, dynamic> json) {
|
||||
return FamilyMember(
|
||||
userId: json['userId'] as String? ?? '',
|
||||
nickname: json['nickname'] as String? ?? '',
|
||||
role: json['role'] as String? ?? 'member',
|
||||
joinedAt: json['joinedAt'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Family {
|
||||
final String id;
|
||||
final String name;
|
||||
final String inviteCode;
|
||||
final String createdBy;
|
||||
final String createdAt;
|
||||
final List<FamilyMember> members;
|
||||
|
||||
const Family({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.inviteCode,
|
||||
this.createdBy = '',
|
||||
this.createdAt = '',
|
||||
this.members = const [],
|
||||
});
|
||||
|
||||
factory Family.fromJson(Map<String, dynamic> json) {
|
||||
return Family(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
inviteCode: json['inviteCode'] as String? ?? '',
|
||||
createdBy: json['createdBy'] as String? ?? '',
|
||||
createdAt: json['createdAt'] as String? ?? '',
|
||||
members: (json['members'] as List<dynamic>?)
|
||||
?.map((m) => FamilyMember.fromJson(m as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
45
frontend/lib/models/user.dart
Normal file
45
frontend/lib/models/user.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
class User {
|
||||
final String id;
|
||||
final String phone;
|
||||
final String nickname;
|
||||
final String? email;
|
||||
final List<String> familyIds;
|
||||
final String? activeFamilyId;
|
||||
final String joinDate;
|
||||
final String createdAt;
|
||||
|
||||
const User({
|
||||
required this.id,
|
||||
required this.phone,
|
||||
required this.nickname,
|
||||
this.email,
|
||||
this.familyIds = const [],
|
||||
this.activeFamilyId,
|
||||
this.joinDate = '',
|
||||
this.createdAt = '',
|
||||
});
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'] as String? ?? '',
|
||||
phone: json['phone'] as String? ?? '',
|
||||
nickname: json['nickname'] as String? ?? '',
|
||||
email: json['email'] as String?,
|
||||
familyIds: (json['familyIds'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
activeFamilyId: json['activeFamilyId'] as String?,
|
||||
joinDate: json['joinDate'] as String? ?? '',
|
||||
createdAt: json['createdAt'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'phone': phone,
|
||||
'nickname': nickname,
|
||||
'email': email,
|
||||
'familyIds': familyIds,
|
||||
'activeFamilyId': activeFamilyId,
|
||||
'joinDate': joinDate,
|
||||
'createdAt': createdAt,
|
||||
};
|
||||
}
|
||||
113
frontend/lib/providers/auth_provider.dart
Normal file
113
frontend/lib/providers/auth_provider.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../api/api_client.dart';
|
||||
import '../api/auth_api.dart';
|
||||
import '../models/user.dart';
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
|
||||
|
||||
final authApiProvider = Provider<AuthApi>((ref) {
|
||||
return AuthApi(ref.read(apiClientProvider));
|
||||
});
|
||||
|
||||
class AuthState {
|
||||
final bool isLoggedIn;
|
||||
final User? user;
|
||||
final String? token;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const AuthState({
|
||||
this.isLoggedIn = false,
|
||||
this.user,
|
||||
this.token,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
AuthState copyWith({
|
||||
bool? isLoggedIn,
|
||||
User? user,
|
||||
String? token,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return AuthState(
|
||||
isLoggedIn: isLoggedIn ?? this.isLoggedIn,
|
||||
user: user ?? this.user,
|
||||
token: token ?? this.token,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error ?? this.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final AuthApi _api;
|
||||
final ApiClient _client;
|
||||
|
||||
AuthNotifier(this._api, this._client) : super(const AuthState());
|
||||
|
||||
Future<void> login(String phone, String password) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
try {
|
||||
final res = await _api.login(phone, password);
|
||||
if (res['success'] == true) {
|
||||
final token = res['data']['token'] as String;
|
||||
final user = User.fromJson(res['data']['user']);
|
||||
await _client.saveToken(token);
|
||||
state = AuthState(isLoggedIn: true, user: user, token: token);
|
||||
} else {
|
||||
state = state.copyWith(isLoading: false, error: res['error'] as String?);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: '网络错误: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> register(String phone, String password, String nickname) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
try {
|
||||
final res = await _api.register(phone, password, nickname);
|
||||
if (res['success'] == true) {
|
||||
final token = res['data']['token'] as String;
|
||||
final user = User.fromJson(res['data']['user']);
|
||||
await _client.saveToken(token);
|
||||
state = AuthState(isLoggedIn: true, user: user, token: token);
|
||||
} else {
|
||||
state = state.copyWith(isLoading: false, error: res['error'] as String?);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: '网络错误: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _client.clearToken();
|
||||
state = const AuthState();
|
||||
}
|
||||
|
||||
Future<void> restoreSession() async {
|
||||
final token = await _client.getToken();
|
||||
if (token == null) return;
|
||||
try {
|
||||
final res = await _api.getProfile();
|
||||
if (res['success'] == true) {
|
||||
state = AuthState(isLoggedIn: true, user: User.fromJson(res['data']), token: token);
|
||||
} else {
|
||||
await _client.clearToken();
|
||||
}
|
||||
} catch (_) {
|
||||
// offline - keep token for retry
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
}
|
||||
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
final api = ref.read(authApiProvider);
|
||||
final client = ref.read(apiClientProvider);
|
||||
return AuthNotifier(api, client);
|
||||
});
|
||||
45
frontend/lib/providers/bill_provider.dart
Normal file
45
frontend/lib/providers/bill_provider.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../api/bill_api.dart';
|
||||
import '../api/api_client.dart';
|
||||
import '../models/bill.dart';
|
||||
|
||||
final billApiProvider = Provider<BillApi>((ref) {
|
||||
return BillApi(ref.read(apiClientProvider));
|
||||
});
|
||||
|
||||
class BillNotifier extends StateNotifier<AsyncValue<List<Bill>>> {
|
||||
final BillApi _api;
|
||||
String? _currentFamilyId;
|
||||
|
||||
BillNotifier(this._api) : super(const AsyncValue.data([]));
|
||||
|
||||
Future<void> loadBills({String? familyId}) async {
|
||||
_currentFamilyId = familyId;
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final res = await _api.getBills(familyId: familyId);
|
||||
if (res['success'] == true) {
|
||||
state = AsyncValue.data((res['data'] as List).map((e) => Bill.fromJson(e)).toList());
|
||||
} else {
|
||||
state = AsyncValue.error(res['error'] ?? '加载失败', StackTrace.current);
|
||||
}
|
||||
} catch (e, st) {
|
||||
state = AsyncValue.error(e.toString(), st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> addBill(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final res = await _api.createBill(data);
|
||||
if (res['success'] == true) {
|
||||
await loadBills(familyId: _currentFamilyId);
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final billProvider = StateNotifierProvider<BillNotifier, AsyncValue<List<Bill>>>((ref) {
|
||||
return BillNotifier(ref.read(billApiProvider));
|
||||
});
|
||||
52
frontend/lib/providers/family_provider.dart
Normal file
52
frontend/lib/providers/family_provider.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../api/family_api.dart';
|
||||
import '../api/api_client.dart';
|
||||
import '../models/family.dart';
|
||||
|
||||
final familyApiProvider = Provider<FamilyApi>((ref) {
|
||||
return FamilyApi(ref.read(apiClientProvider));
|
||||
});
|
||||
|
||||
class FamilyNotifier extends StateNotifier<AsyncValue<List<Family>>> {
|
||||
final FamilyApi _api;
|
||||
|
||||
FamilyNotifier(this._api) : super(const AsyncValue.loading());
|
||||
|
||||
Future<void> loadFamilies() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final res = await _api.getFamilies();
|
||||
if (res['success'] == true) {
|
||||
state = AsyncValue.data((res['data'] as List).map((e) => Family.fromJson(e)).toList());
|
||||
} else {
|
||||
state = AsyncValue.error(res['error'] ?? '加载失败', StackTrace.current);
|
||||
}
|
||||
} catch (e, st) {
|
||||
state = AsyncValue.error(e.toString(), st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Family?> createFamily(String name) async {
|
||||
try {
|
||||
final res = await _api.createFamily(name);
|
||||
if (res['success'] == true) {
|
||||
return Family.fromJson(res['data']);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Family?> joinFamily(String inviteCode) async {
|
||||
try {
|
||||
final res = await _api.joinFamily(inviteCode);
|
||||
if (res['success'] == true) {
|
||||
return Family.fromJson(res['data']);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final familyProvider = StateNotifierProvider<FamilyNotifier, AsyncValue<List<Family>>>((ref) {
|
||||
return FamilyNotifier(ref.read(familyApiProvider));
|
||||
});
|
||||
399
frontend/lib/screens/add_bill/add_bill_screen.dart
Normal file
399
frontend/lib/screens/add_bill/add_bill_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
204
frontend/lib/screens/auth/bind_email_screen.dart
Normal file
204
frontend/lib/screens/auth/bind_email_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
215
frontend/lib/screens/auth/login_screen.dart
Normal file
215
frontend/lib/screens/auth/login_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
254
frontend/lib/screens/auth/register_screen.dart
Normal file
254
frontend/lib/screens/auth/register_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
389
frontend/lib/screens/auth/user_profile_screen.dart
Normal file
389
frontend/lib/screens/auth/user_profile_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
425
frontend/lib/screens/family/family_management_screen.dart
Normal file
425
frontend/lib/screens/family/family_management_screen.dart
Normal 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('已切换家庭')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
435
frontend/lib/screens/profile/profile_screen.dart
Normal file
435
frontend/lib/screens/profile/profile_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
696
frontend/lib/screens/statistics/statistics_screen.dart
Normal file
696
frontend/lib/screens/statistics/statistics_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
frontend/lib/theme/app_theme.dart
Normal file
22
frontend/lib/theme/app_theme.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'colors.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData get light => ThemeData(
|
||||
useMaterial3: true,
|
||||
scaffoldBackgroundColor: AppColors.pageBg,
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: AppColors.brand,
|
||||
secondary: AppColors.brandLight,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
13
frontend/lib/theme/colors.dart
Normal file
13
frontend/lib/theme/colors.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
static const brand = Color(0xFFFF6B35);
|
||||
static const brandLight = Color(0xFFFF9F5B);
|
||||
static const brandBg = Color(0xFFFFF0E8);
|
||||
static const income = Color(0xFF34C759);
|
||||
static const expense = Color(0xFFFF3B30);
|
||||
static const textPrimary = Color(0xFF1C1C1E);
|
||||
static const textSecondary = Color(0xFF8E8E93);
|
||||
static const cardBg = Color(0xA6FFFFFF);
|
||||
static const pageBg = Color(0xFFF2F2F7);
|
||||
}
|
||||
59
frontend/lib/widgets/category_grid.dart
Normal file
59
frontend/lib/widgets/category_grid.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/colors.dart';
|
||||
|
||||
class CategoryGrid extends StatelessWidget {
|
||||
final List<Map<String, String>> categories;
|
||||
final String selected;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
const CategoryGrid({
|
||||
super.key,
|
||||
required this.categories,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.count(
|
||||
crossAxisCount: 4,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 1.1,
|
||||
children: categories.map((cat) {
|
||||
final isSelected = selected == cat['name'];
|
||||
return GestureDetector(
|
||||
onTap: () => onSelect(cat['name']!),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.brand.withOpacity(0.1) : Colors.white.withOpacity(0.65),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppColors.brand : Colors.white.withOpacity(0.5),
|
||||
width: isSelected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(cat['emoji'] ?? '', style: const TextStyle(fontSize: 22)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
cat['name'] ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected ? AppColors.brand : const Color(0xFF8E8E93),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
frontend/lib/widgets/category_icon.dart
Normal file
40
frontend/lib/widgets/category_icon.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/colors.dart';
|
||||
|
||||
class CategoryIcon extends StatelessWidget {
|
||||
final String emoji;
|
||||
final String category;
|
||||
final double size;
|
||||
|
||||
const CategoryIcon({
|
||||
super.key,
|
||||
required this.emoji,
|
||||
required this.category,
|
||||
this.size = 40,
|
||||
});
|
||||
|
||||
Color _bgColor() {
|
||||
switch (category) {
|
||||
case '餐饮': return AppColors.brand.withOpacity(0.08);
|
||||
case '交通': return AppColors.brandLight.withOpacity(0.08);
|
||||
case '购物': return AppColors.brand.withOpacity(0.06);
|
||||
case '医疗': return AppColors.expense.withOpacity(0.06);
|
||||
case '工资': return AppColors.income.withOpacity(0.08);
|
||||
default: return AppColors.textSecondary.withOpacity(0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: _bgColor(),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2))],
|
||||
),
|
||||
child: Center(child: Text(emoji, style: TextStyle(fontSize: size * 0.45))),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
frontend/lib/widgets/glass_card.dart
Normal file
55
frontend/lib/widgets/glass_card.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/colors.dart';
|
||||
|
||||
class GlassCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double borderRadius;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final Color? borderColor;
|
||||
final List<BoxShadow>? boxShadow;
|
||||
|
||||
const GlassCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.borderRadius = 16,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderColor,
|
||||
this.boxShadow,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
margin: margin,
|
||||
padding: padding ?? const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardBg,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: Border.all(color: borderColor ?? Colors.white.withOpacity(0.8)),
|
||||
boxShadow: boxShadow ?? [
|
||||
BoxShadow(
|
||||
color: AppColors.brand.withOpacity(0.06),
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
frontend/lib/widgets/number_pad.dart
Normal file
48
frontend/lib/widgets/number_pad.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/colors.dart';
|
||||
|
||||
class NumberPad extends StatelessWidget {
|
||||
final void Function(String key) onKeyTap;
|
||||
final String? Function()? onDelete;
|
||||
|
||||
const NumberPad({super.key, required this.onKeyTap, this.onDelete});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keys = ['1','2','3','4','5','6','7','8','9','.', '0', 'delete'];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: GridView.count(
|
||||
crossAxisCount: 4,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 1.5,
|
||||
children: keys.map((key) {
|
||||
final isDelete = key == 'delete';
|
||||
return GestureDetector(
|
||||
onTap: () => onKeyTap(key),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: isDelete ? AppColors.brand.withOpacity(0.08) : Colors.white.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
isDelete ? '⌫' : key,
|
||||
style: TextStyle(
|
||||
fontSize: isDelete ? 20 : 22,
|
||||
fontWeight: isDelete ? FontWeight.w400 : FontWeight.w500,
|
||||
color: isDelete ? AppColors.brand : const Color(0xFF1C1C1E),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
frontend/lib/widgets/stat_card.dart
Normal file
52
frontend/lib/widgets/stat_card.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class StatCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
final double? fontSize;
|
||||
|
||||
const StatCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
this.fontSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xA6FFFFFF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.8)),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Color(0x0DFF6B35), blurRadius: 32, offset: Offset(0, 8)),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF8E8E93), fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize ?? (value.length > 10 ? 13 : 15),
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user