V1 - Personal Account and Budgeting Done

Personal Account and Budgeting
This commit is contained in:
2026-08-16 20:13:21 +05:30
commit 17c0526ed6
247 changed files with 19002 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../main.dart';
import '../../features/auth/presentation/auth_screen.dart';
class DioClient {
static final DioClient _instance = DioClient._internal();
final Dio dio;
final FlutterSecureStorage storage;
factory DioClient() {
return _instance;
}
DioClient._internal()
: dio = Dio(BaseOptions(
baseUrl: 'https://app.technobeesolutions.in/api/kifi',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
)),
storage = const FlutterSecureStorage() {
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await storage.read(key: 'jwt_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onError: (DioException error, handler) async {
if (error.response?.statusCode == 401) {
// Token is invalid or expired
await storage.delete(key: 'jwt_token');
if (navigatorKey.currentContext != null) {
Navigator.of(navigatorKey.currentContext!).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const AuthScreen()),
(route) => false,
);
}
}
String cleanMessage = "An unexpected error occurred.";
if (error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.sendTimeout || error.type == DioExceptionType.receiveTimeout) {
cleanMessage = "Network error: Unable to connect to server. Please check your internet connection.";
} else if (error.response?.statusCode == 500) {
cleanMessage = "Server error. Please try again later.";
} else if (error.response?.data != null && error.response!.data is Map && error.response!.data['message'] != null) {
cleanMessage = error.response!.data['message'];
} else if (error.type == DioExceptionType.connectionError) {
cleanMessage = "Network error: Unable to connect to server.";
}
return handler.reject(
DioException(
requestOptions: error.requestOptions,
error: CleanException(cleanMessage),
type: error.type,
response: error.response,
)
);
},
));
}
}
class CleanException implements Exception {
final String message;
CleanException(this.message);
@override
String toString() => message;
}

View File

@@ -0,0 +1,38 @@
import 'package:encrypt/encrypt.dart';
import 'package:pointycastle/asymmetric/api.dart';
import 'package:dio/dio.dart';
import '../network/dio_client.dart';
class CryptoService {
static final CryptoService _instance = CryptoService._internal();
factory CryptoService() => _instance;
CryptoService._internal();
RSAPublicKey? _publicKey;
Future<void> fetchPublicKey() async {
try {
final dio = Dio(BaseOptions(
baseUrl: DioClient().dio.options.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
final response = await dio.get('/auth/public-key');
final publicKeyBase64 = response.data['publicKey'] as String;
_publicKey = RSAKeyParser().parse('-----BEGIN PUBLIC KEY-----\n$publicKeyBase64\n-----END PUBLIC KEY-----') as RSAPublicKey;
} catch (e) {
throw Exception('Failed to fetch public key: $e');
}
}
String encrypt(String plainText) {
if (_publicKey == null) {
throw Exception('Public key not initialized. Call fetchPublicKey() first.');
}
final encrypter = Encrypter(RSA(publicKey: _publicKey));
final encrypted = encrypter.encrypt(plainText);
return encrypted.base64;
}
bool get isInitialized => _publicKey != null;
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import 'package:timezone/timezone.dart' as tz;
import 'package:flutter/foundation.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
bool _initialized = false;
Future<void> initialize() async {
if (_initialized) return;
tz.initializeTimeZones();
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final DarwinInitializationSettings initializationSettingsDarwin =
DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
final InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsDarwin,
);
await _flutterLocalNotificationsPlugin.initialize(settings: initializationSettings);
_initialized = true;
}
Future<void> scheduleNotification({
required int id,
required String title,
required String body,
required DateTime scheduledDate,
}) async {
if (!_initialized) await initialize();
try {
await _flutterLocalNotificationsPlugin.zonedSchedule(
id: id,
title: title,
body: body,
scheduledDate: tz.TZDateTime.from(scheduledDate, tz.local),
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
'payables_alerts_channel',
'Payables Alerts',
channelDescription: 'Notifications for upcoming payable due dates',
importance: Importance.max,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
} catch (e) {
debugPrint('Error scheduling notification: $e');
}
}
Future<void> cancelNotification(int id) async {
await _flutterLocalNotificationsPlugin.cancel(id: id);
}
}

View File

@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'package:image_picker/image_picker.dart';
class OcrResult {
final double? amount;
final String? rawText;
OcrResult({this.amount, this.rawText});
}
class OcrService {
final ImagePicker _picker = ImagePicker();
Future<OcrResult?> scanReceiptFromCamera() async {
final XFile? image = await _picker.pickImage(source: ImageSource.camera);
if (image == null) return null;
return await _processImage(File(image.path));
}
Future<OcrResult?> scanReceiptFromGallery() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image == null) return null;
return await _processImage(File(image.path));
}
Future<OcrResult> _processImage(File file) async {
final inputImage = InputImage.fromFile(file);
final textRecognizer = TextRecognizer(script: TextRecognitionScript.latin);
try {
final RecognizedText recognizedText = await textRecognizer.processImage(inputImage);
String text = recognizedText.text;
// Simple regex to find a monetary amount (e.g., $145.50 or 145.50)
final RegExp amountRegex = RegExp(r'\$?\d+\.\d{2}');
final Iterable<Match> matches = amountRegex.allMatches(text);
double? maxAmount;
for (final Match m in matches) {
final matchText = m.group(0)?.replaceAll('\$', '');
if (matchText != null) {
final amount = double.tryParse(matchText);
if (amount != null) {
if (maxAmount == null || amount > maxAmount) {
maxAmount = amount; // Usually the total is the largest amount
}
}
}
}
return OcrResult(
amount: maxAmount,
rawText: text,
);
} finally {
textRecognizer.close();
}
}
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class AppTheme {
static const Color primaryColor = Color(0xFF6C63FF);
static const Color secondaryColor = Color(0xFFFF6584);
static const Color backgroundColor = Color(0xFFF7F9FC);
static const Color surfaceColor = Colors.white;
static const Color textPrimaryColor = Color(0xFF2D3142);
static const Color textSecondaryColor = Color(0xFF9094A6);
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.light(
primary: primaryColor,
secondary: secondaryColor,
surface: surfaceColor,
onSurface: textPrimaryColor,
),
scaffoldBackgroundColor: backgroundColor,
textTheme: GoogleFonts.outfitTextTheme().copyWith(
displayLarge: GoogleFonts.outfit(color: textPrimaryColor, fontWeight: FontWeight.bold, fontSize: 32),
bodyLarge: GoogleFonts.outfit(color: textPrimaryColor, fontSize: 16),
bodyMedium: GoogleFonts.outfit(color: textSecondaryColor, fontSize: 14),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
hintStyle: const TextStyle(color: textSecondaryColor),
),
cardTheme: CardThemeData(
color: surfaceColor,
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.05),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
static ThemeData get darkTheme {
const Color darkBackground = Color(0xFF121212);
const Color darkSurface = Color(0xFF1E1E1E);
const Color darkTextPrimary = Color(0xFFE0E0E0);
const Color darkTextSecondary = Color(0xFFA0A0A0);
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
primary: primaryColor,
secondary: secondaryColor,
surface: darkSurface,
onSurface: darkTextPrimary,
),
scaffoldBackgroundColor: darkBackground,
textTheme: GoogleFonts.outfitTextTheme().copyWith(
displayLarge: GoogleFonts.outfit(color: darkTextPrimary, fontWeight: FontWeight.bold, fontSize: 32),
bodyLarge: GoogleFonts.outfit(color: darkTextPrimary, fontSize: 16),
bodyMedium: GoogleFonts.outfit(color: darkTextSecondary, fontSize: 14),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: darkSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
hintStyle: const TextStyle(color: darkTextSecondary),
),
cardTheme: CardThemeData(
color: darkSurface,
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
class NatureColors {
static Color getColor(String nature) {
switch (nature) {
case 'INCOME':
return Colors.green.shade700;
case 'RECEIVABLES':
case 'LENDING':
return Colors.lightGreen;
case 'EXPENSE':
return Colors.red;
case 'PAYABLES':
case 'LOAN':
return Colors.orange;
case 'INVESTMENTS':
case 'INVESTMENT':
return Colors.blue;
case 'SAVINGS':
return Colors.teal;
case 'CASH':
case 'BALANCE':
return Colors.blueGrey;
case 'TRANSFER':
return Colors.blueGrey;
default:
return Colors.grey;
}
}
static List<Color> getPalette(String nature) {
switch (nature) {
case 'INCOME':
return [Colors.green.shade900, Colors.green.shade700, Colors.green.shade500, Colors.green.shade300, Colors.greenAccent.shade700, Colors.teal.shade400, Colors.lightGreen.shade400];
case 'RECEIVABLES':
case 'LENDING':
return [Colors.lightGreen.shade900, Colors.lightGreen.shade700, Colors.lightGreen, Colors.lime.shade600, Colors.lightGreenAccent.shade700, Colors.green.shade400];
case 'EXPENSE':
return [Colors.red.shade900, Colors.red.shade700, Colors.red, Colors.deepOrange.shade400, Colors.pink.shade400, Colors.redAccent.shade700, Colors.orange.shade800];
case 'PAYABLES':
case 'LOAN':
return [Colors.orange.shade900, Colors.orange.shade700, Colors.orange, Colors.deepOrange.shade600, Colors.amber.shade700, Colors.orangeAccent.shade700, Colors.brown.shade400];
case 'INVESTMENTS':
case 'INVESTMENT':
return [Colors.blue.shade900, Colors.blue.shade700, Colors.blue, Colors.lightBlue.shade600, Colors.cyan.shade600, Colors.blueAccent.shade700, Colors.indigo.shade400];
default:
return [Colors.blueGrey.shade800, Colors.blueGrey.shade600, Colors.blueGrey, Colors.grey.shade600, Colors.grey.shade400, Colors.black54];
}
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeNotifier extends Notifier<ThemeMode> {
static const _themePrefKey = 'theme_pref';
@override
ThemeMode build() {
_loadTheme();
return ThemeMode.system;
}
Future<void> _loadTheme() async {
final prefs = await SharedPreferences.getInstance();
final themeString = prefs.getString(_themePrefKey);
if (themeString != null) {
if (themeString == 'light') {
state = ThemeMode.light;
} else if (themeString == 'dark') {
state = ThemeMode.dark;
}
}
}
Future<void> setTheme(ThemeMode mode) async {
state = mode;
final prefs = await SharedPreferences.getInstance();
if (mode == ThemeMode.light) {
await prefs.setString(_themePrefKey, 'light');
} else if (mode == ThemeMode.dark) {
await prefs.setString(_themePrefKey, 'dark');
} else {
await prefs.setString(_themePrefKey, 'system');
}
}
}
final themeProvider = NotifierProvider<ThemeNotifier, ThemeMode>(() {
return ThemeNotifier();
});

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
class SnackBarService {
static void showSuccess(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(LucideIcons.checkCircle, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
backgroundColor: Colors.green.shade600,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
margin: const EdgeInsets.all(16),
),
);
}
static void showError(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(LucideIcons.alertCircle, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
backgroundColor: Colors.red.shade600,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
margin: const EdgeInsets.all(16),
),
);
}
static void showWarning(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(LucideIcons.alertTriangle, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
backgroundColor: Colors.orange.shade700,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 4),
),
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
class EmptyStateWidget extends StatelessWidget {
final IconData icon;
final String title;
final String message;
const EmptyStateWidget({
super.key,
required this.icon,
required this.title,
required this.message,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 80,
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
),
const SizedBox(height: 24),
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
class ShimmerLoading extends StatelessWidget {
final double width;
final double height;
final double borderRadius;
const ShimmerLoading({
super.key,
required this.width,
required this.height,
this.borderRadius = 8.0,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Shimmer.fromColors(
baseColor: isDark ? Colors.grey.shade800 : Colors.grey.shade300,
highlightColor: isDark ? Colors.grey.shade700 : Colors.grey.shade100,
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(borderRadius),
),
),
);
}
}
class ShimmerCard extends StatelessWidget {
const ShimmerCard({super.key});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShimmerLoading(width: 120, height: 16),
const SizedBox(height: 16),
const ShimmerLoading(width: double.infinity, height: 24),
const SizedBox(height: 8),
const ShimmerLoading(width: double.infinity, height: 24),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
ShimmerLoading(width: 60, height: 16),
ShimmerLoading(width: 60, height: 16),
],
)
],
),
),
);
}
}

View File

@@ -0,0 +1,84 @@
import 'package:dio/dio.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/security/crypto_service.dart';
class AuthRepository {
final Dio dio = DioClient().dio;
final CryptoService _crypto = CryptoService();
Future<void> _ensureCryptoReady() async {
if (!_crypto.isInitialized) {
await _crypto.fetchPublicKey();
}
}
Future<void> signup(String email, String password) async {
try {
await _ensureCryptoReady();
await dio.post('/auth/signup', data: {
'email': _crypto.encrypt(email),
'password': _crypto.encrypt(password),
});
} on DioException catch (e) {
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? e.message);
}
}
Future<Map<String, dynamic>> login(String email, String password) async {
try {
await _ensureCryptoReady();
final response = await dio.post('/auth/login', data: {
'email': _crypto.encrypt(email),
'password': _crypto.encrypt(password),
});
return response.data;
} on DioException catch (e) {
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? 'Invalid email or password');
}
}
Future<Map<String, dynamic>> verifyOtp(String email, String otp) async {
try {
final response = await dio.post('/auth/verify-otp', data: {
'email': email,
'otp': otp,
});
return response.data;
} on DioException catch (e) {
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? 'Invalid OTP');
}
}
Future<void> forgotPassword(String email) async {
try {
await dio.post('/auth/forgot-password', data: {
'email': email,
});
} on DioException catch (e) {
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? 'Failed to send reset code');
}
}
Future<void> resetPassword(String email, String otp, String newPassword) async {
try {
await _ensureCryptoReady();
await dio.post('/auth/reset-password', data: {
'email': email,
'otp': otp,
'newPassword': _crypto.encrypt(newPassword),
});
} on DioException catch (e) {
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? 'Failed to reset password');
}
}
}

View File

@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/auth_provider.dart';
import 'otp_screen.dart';
import 'forgot_password_screen.dart';
import '../../dashboard/presentation/dashboard_screen.dart';
class AuthScreen extends ConsumerStatefulWidget {
const AuthScreen({super.key});
@override
ConsumerState<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends ConsumerState<AuthScreen> {
bool isLogin = true;
final emailController = TextEditingController();
final passwordController = TextEditingController();
void toggleMode() {
setState(() {
isLogin = !isLogin;
});
}
Future<void> submit() async {
final email = emailController.text.trim();
final password = passwordController.text;
if (email.isEmpty || password.isEmpty) return;
if (isLogin) {
final success = await ref.read(authControllerProvider.notifier).login(email, password);
if (success && mounted) {
Navigator.pushReplacement(
context, MaterialPageRoute(builder: (_) => const DashboardScreen()));
}
} else {
final success = await ref.read(authControllerProvider.notifier).signup(email, password);
if (success && mounted) {
Navigator.push(context, MaterialPageRoute(builder: (_) => OtpScreen(email: email)));
}
}
}
@override
Widget build(BuildContext context) {
ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) {
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${next.error}')),
);
}
});
final state = ref.watch(authControllerProvider);
final isLoading = state.isLoading;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
isLogin ? 'Welcome Back!' : 'Create Account',
style: Theme.of(context).textTheme.displayLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
isLogin ? 'Login to continue to Kifi' : 'Sign up to manage your expenses',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Email Address',
prefixIcon: Icon(LucideIcons.mail, color: Colors.grey),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
obscureText: true,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Password',
prefixIcon: Icon(LucideIcons.lock, color: Colors.grey),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (isLogin) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const ForgotPasswordScreen()));
},
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF6C63FF),
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('Forgot Password?', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
),
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: isLoading ? null : submit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(isLogin ? 'Login' : 'Sign Up', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 16),
TextButton(
onPressed: toggleMode,
child: Text(isLogin ? 'Don\'t have an account? Sign Up' : 'Already have an account? Login'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/auth_provider.dart';
import 'reset_password_screen.dart';
class ForgotPasswordScreen extends ConsumerStatefulWidget {
const ForgotPasswordScreen({super.key});
@override
ConsumerState<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
final emailController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
emailController.dispose();
super.dispose();
}
Future<void> _sendOtp() async {
final email = emailController.text.trim();
if (email.isEmpty || !email.contains('@')) {
setState(() => _errorMessage = 'Please enter a valid email address');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final success = await ref.read(authControllerProvider.notifier).forgotPassword(email);
if (mounted) {
setState(() => _isLoading = false);
if (success) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => ResetPasswordScreen(email: email)),
);
} else {
setState(() => _errorMessage = 'Failed to send reset code. Please check your email.');
}
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
style: IconButton.styleFrom(
backgroundColor: Colors.grey.shade100,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 40),
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6C63FF), Color(0xFF48C6EF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(LucideIcons.keyRound, color: Colors.white, size: 36),
),
),
const SizedBox(height: 32),
const Center(
child: Text(
'Forgot Password?',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
Center(
child: Text(
'Enter your email address and we\'ll send you\na verification code to reset your password.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: Colors.grey.shade600, height: 1.5),
),
),
const SizedBox(height: 48),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _sendOtp(),
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Email Address',
prefixIcon: Icon(LucideIcons.mail, color: Colors.grey.shade500),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 13))),
],
),
),
],
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _sendOtp,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Send Reset Code', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/auth_provider.dart';
import '../../dashboard/presentation/dashboard_screen.dart';
class OtpScreen extends ConsumerStatefulWidget {
final String email;
const OtpScreen({super.key, required this.email});
@override
ConsumerState<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends ConsumerState<OtpScreen> {
final otpController = TextEditingController();
Future<void> verify() async {
final otp = otpController.text.trim();
if (otp.isEmpty) return;
final success = await ref.read(authControllerProvider.notifier).verifyOtp(widget.email, otp);
if (success && mounted) {
Navigator.pushAndRemoveUntil(
context, MaterialPageRoute(builder: (_) => const DashboardScreen()), (route) => false);
}
}
@override
Widget build(BuildContext context) {
ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) {
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${next.error}')),
);
}
});
final state = ref.watch(authControllerProvider);
final isLoading = state.isLoading;
return Scaffold(
appBar: AppBar(backgroundColor: Colors.transparent, elevation: 0),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Verify Email',
style: Theme.of(context).textTheme.displayLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Enter the 6-digit OTP sent to ${widget.email}',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: otpController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
style: const TextStyle(fontSize: 24, letterSpacing: 8, fontWeight: FontWeight.bold, color: Color(0xFF6C63FF)),
decoration: InputDecoration(
hintText: '000000',
hintStyle: TextStyle(letterSpacing: 8, color: Colors.grey.shade400),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: isLoading ? null : verify,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Verify', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,148 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart';
import 'auth_screen.dart';
import '../../../core/network/dio_client.dart';
import '../providers/auth_provider.dart';
import '../../transactions/providers/providers.dart';
import '../../../core/theme/theme_provider.dart';
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@override
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
bool _isExporting = false;
Future<void> _logout(BuildContext context) async {
const storage = FlutterSecureStorage();
await storage.delete(key: 'jwt_token');
if (context.mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const AuthScreen()),
(route) => false,
);
}
}
Future<void> _exportData() async {
setState(() => _isExporting = true);
try {
final bytes = await ref.read(apiRepositoryProvider).exportTransactions();
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/transactions_export.csv');
await file.writeAsBytes(bytes);
final xFile = XFile(file.path);
await Share.shareXFiles([xFile], text: 'Here is my Kifi transactions export.');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
} finally {
if (mounted) {
setState(() => _isExporting = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Profile'),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 20),
CircleAvatar(
radius: 50,
backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.1),
child: Icon(LucideIcons.user, size: 50, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 24),
const Text('Kifi User', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('maddy23285@gmail.com', style: TextStyle(color: Colors.grey, fontSize: 16)),
const SizedBox(height: 48),
Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Column(
children: [
ListTile(
leading: const Icon(LucideIcons.downloadCloud),
title: const Text('Export Data to CSV'),
trailing: _isExporting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(LucideIcons.chevronRight),
onTap: _isExporting ? null : _exportData,
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.moon),
title: const Text('Theme'),
trailing: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(value: ThemeMode.light, icon: Icon(LucideIcons.sun)),
ButtonSegment(value: ThemeMode.system, icon: Icon(LucideIcons.monitor)),
ButtonSegment(value: ThemeMode.dark, icon: Icon(LucideIcons.moon)),
],
selected: {ref.watch(themeProvider)},
onSelectionChanged: (Set<ThemeMode> newSelection) {
ref.read(themeProvider.notifier).setTheme(newSelection.first);
},
showSelectedIcon: false,
style: ButtonStyle(visualDensity: VisualDensity.compact),
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.helpCircle),
title: const Text('Help & Support'),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () {},
),
],
),
),
const Spacer(),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _logout(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.withOpacity(0.1),
foregroundColor: Colors.red,
padding: const EdgeInsets.symmetric(vertical: 16),
),
icon: const Icon(LucideIcons.logOut),
label: const Text('Log Out', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 20),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/auth_provider.dart';
import 'auth_screen.dart';
class ResetPasswordScreen extends ConsumerStatefulWidget {
final String email;
const ResetPasswordScreen({super.key, required this.email});
@override
ConsumerState<ResetPasswordScreen> createState() => _ResetPasswordScreenState();
}
class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
final otpController = TextEditingController();
final newPasswordController = TextEditingController();
final confirmPasswordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirm = true;
String? _errorMessage;
@override
void dispose() {
otpController.dispose();
newPasswordController.dispose();
confirmPasswordController.dispose();
super.dispose();
}
Future<void> _resetPassword() async {
final otp = otpController.text.trim();
final newPassword = newPasswordController.text;
final confirmPassword = confirmPasswordController.text;
if (otp.isEmpty || otp.length != 6) {
setState(() => _errorMessage = 'Please enter a valid 6-digit OTP');
return;
}
if (newPassword.isEmpty || newPassword.length < 6) {
setState(() => _errorMessage = 'Password must be at least 6 characters');
return;
}
if (newPassword != confirmPassword) {
setState(() => _errorMessage = 'Passwords do not match');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final success = await ref.read(authControllerProvider.notifier).resetPassword(widget.email, otp, newPassword);
if (mounted) {
setState(() => _isLoading = false);
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password reset successfully! Please log in.'),
backgroundColor: Color(0xFF6C63FF),
),
);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const AuthScreen()),
(route) => false,
);
} else {
setState(() => _errorMessage = 'Invalid OTP or reset failed. Please try again.');
}
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
style: IconButton.styleFrom(
backgroundColor: Colors.grey.shade100,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 32),
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6C63FF), Color(0xFF48C6EF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(LucideIcons.shieldCheck, color: Colors.white, size: 36),
),
),
const SizedBox(height: 28),
const Center(
child: Text(
'Reset Password',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
Center(
child: Text(
'We\'ve sent a 6-digit code to\n${widget.email}',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: Colors.grey.shade600, height: 1.5),
),
),
const SizedBox(height: 40),
// OTP Field
TextField(
controller: otpController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
maxLength: 6,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 20, letterSpacing: 8),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '000000',
hintStyle: TextStyle(color: Colors.grey.shade300, letterSpacing: 8),
counterText: '',
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 20),
// New Password
TextField(
controller: newPasswordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.next,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'New Password',
prefixIcon: Icon(LucideIcons.lock, color: Colors.grey.shade500),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
// Confirm Password
TextField(
controller: confirmPasswordController,
obscureText: _obscureConfirm,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _resetPassword(),
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Confirm Password',
prefixIcon: Icon(LucideIcons.lock, color: Colors.grey.shade500),
suffixIcon: IconButton(
icon: Icon(_obscureConfirm ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500),
onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm),
),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 13))),
],
),
),
],
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _resetPassword,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Reset Password', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,81 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/auth_repository.dart';
import '../../../../core/network/dio_client.dart';
final authRepositoryProvider = Provider((ref) => AuthRepository());
class AuthController extends AsyncNotifier<void> {
late AuthRepository _repository;
@override
FutureOr<void> build() {
_repository = ref.watch(authRepositoryProvider);
}
Future<bool> login(String email, String password) async {
state = const AsyncValue.loading();
try {
final data = await _repository.login(email, password);
await DioClient().storage.write(key: 'jwt_token', value: data['token']);
state = const AsyncValue.data(null);
return true;
} catch (e, st) {
state = AsyncValue.error(e, st);
return false;
}
}
Future<bool> signup(String email, String password) async {
state = const AsyncValue.loading();
try {
await _repository.signup(email, password);
state = const AsyncValue.data(null);
return true;
} catch (e, st) {
state = AsyncValue.error(e, st);
return false;
}
}
Future<bool> verifyOtp(String email, String otp) async {
state = const AsyncValue.loading();
try {
final data = await _repository.verifyOtp(email, otp);
await DioClient().storage.write(key: 'jwt_token', value: data['token']);
state = const AsyncValue.data(null);
return true;
} catch (e, st) {
state = AsyncValue.error(e, st);
return false;
}
}
Future<bool> forgotPassword(String email) async {
state = const AsyncValue.loading();
try {
await _repository.forgotPassword(email);
state = const AsyncValue.data(null);
return true;
} catch (e, st) {
state = AsyncValue.error(e, st);
return false;
}
}
Future<bool> resetPassword(String email, String otp, String newPassword) async {
state = const AsyncValue.loading();
try {
await _repository.resetPassword(email, otp, newPassword);
state = const AsyncValue.data(null);
return true;
} catch (e, st) {
state = AsyncValue.error(e, st);
return false;
}
}
}
final authControllerProvider = AsyncNotifierProvider<AuthController, void>(() {
return AuthController();
});

View File

@@ -0,0 +1,425 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart';
import '../../../core/widgets/shimmer_loading.dart';
class BudgetScreen extends ConsumerStatefulWidget {
const BudgetScreen({super.key});
@override
ConsumerState<BudgetScreen> createState() => _BudgetScreenState();
}
class _BudgetScreenState extends ConsumerState<BudgetScreen> {
String _searchQuery = '';
String? _filterNature;
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _onRefresh() async {
ref.invalidate(budgetProvider);
ref.invalidate(walletProvider);
ref.invalidate(transactionProvider);
await Future.delayed(const Duration(milliseconds: 500));
}
void _showSetBudgetDialog(BuildContext context, Wallet wallet, Budget? existingBudget) {
final controller = TextEditingController(text: existingBudget?.monthlyLimit.toString() ?? '');
bool isShared = existingBudget?.isShared ?? false;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setState) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Set Budget for ${wallet.name}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Monthly Limit',
prefixText: 'Rs. ',
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Share budget with wallet members', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
value: isShared,
activeColor: const Color(0xFF6C63FF),
contentPadding: EdgeInsets.zero,
onChanged: (val) {
setState(() {
isShared = val;
});
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () async {
final limit = double.tryParse(controller.text);
if (limit != null && limit > 0) {
final newBudget = Budget(
id: existingBudget?.id ?? 0,
walletId: wallet.id,
monthlyLimit: limit,
isShared: isShared,
);
await ref.read(budgetProvider.notifier).addOrUpdateBudget(newBudget);
if (mounted) Navigator.pop(ctx);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
),
);
}
),
);
}
void _showFilterSheet() {
final natures = ['ALL', 'CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
String? tempFilterNature = _filterNature;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => StatefulBuilder(
builder: (context, setSheetState) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Filter Budgets', style: Theme.of(context).textTheme.titleLarge),
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(ctx)),
],
),
const SizedBox(height: 24),
Text('Account Nature', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: tempFilterNature ?? 'ALL',
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16),
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) {
setSheetState(() {
tempFilterNature = val == 'ALL' ? null : val;
});
}
},
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
setState(() => _filterNature = null);
Navigator.pop(ctx);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
side: BorderSide(color: Colors.grey.shade300),
),
child: Text('Reset', style: TextStyle(color: Colors.grey.shade700, fontWeight: FontWeight.bold)),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
setState(() => _filterNature = tempFilterNature);
Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Apply Filter', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
),
const SizedBox(height: 16),
],
),
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
final budgetsState = ref.watch(budgetProvider);
final walletsState = ref.watch(walletProvider);
final transState = ref.watch(transactionProvider);
return Scaffold(
backgroundColor: Colors.transparent,
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onChanged: (val) => setState(() => _searchQuery = val),
decoration: InputDecoration(
hintText: 'Search budgets...',
prefixIcon: const Icon(LucideIcons.search),
suffixIcon: _searchController.text.isNotEmpty ? IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
) : null,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
LucideIcons.filter,
color: _filterNature != null ? const Color(0xFF6C63FF) : null,
),
onPressed: _showFilterSheet,
),
],
),
),
Expanded(
child: budgetsState.when(
loading: () => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 4,
itemBuilder: (context, index) => const ShimmerCard(),
),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (budgets) {
if (!walletsState.hasValue || !transState.hasValue) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 4,
itemBuilder: (context, index) => const ShimmerCard(),
);
}
final wallets = walletsState.value!.where((w) {
final nature = (w.nature ?? 'CASH').trim().toUpperCase();
final hasBudget = budgets.any((b) => b.walletId == w.id);
final matchSearch = _searchQuery.isEmpty || w.name.toLowerCase().contains(_searchQuery.toLowerCase());
final matchNature = _filterNature == null || nature == _filterNature;
final matchBaseCondition = hasBudget || nature == 'EXPENSE' || nature == 'INVESTMENTS';
return matchBaseCondition && matchSearch && matchNature;
}).toList();
final transactions = transState.value!;
final now = DateTime.now();
// Calculate spent per wallet for the current month
final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year);
final Map<int, double> spentByWallet = {};
for (var t in currentMonthTxs) {
if (t.toWalletId != null) {
spentByWallet[t.toWalletId!] = (spentByWallet[t.toWalletId!] ?? 0) + t.amount;
}
}
if (wallets.isEmpty) {
return const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('No budgets or eligible accounts found.')));
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: wallets.length,
itemBuilder: (context, index) {
final wallet = wallets[index];
final existingBudgetIndex = budgets.indexWhere((b) => b.walletId == wallet.id);
final budget = existingBudgetIndex >= 0 ? budgets[existingBudgetIndex] : null;
final spent = spentByWallet[wallet.id] ?? 0.0;
if (budget == null) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: const CircleAvatar(child: Icon(LucideIcons.wallet)),
title: Text(wallet.name),
subtitle: Text('${wallet.nature} • No budget set'),
trailing: TextButton(
onPressed: () => _showSetBudgetDialog(context, wallet, null),
child: const Text('Set Budget'),
),
),
);
}
final limit = budget.monthlyLimit;
final double progress;
if (limit <= 0) {
progress = 1.0;
} else {
progress = (spent / limit).clamp(0.0, 1.0);
}
Color progressColor = Colors.green;
if (progress > 0.9) progressColor = Colors.red;
else if (progress > 0.7) progressColor = Colors.orange;
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
const Icon(LucideIcons.wallet, size: 20, color: Colors.blue),
const SizedBox(width: 8),
Expanded(child: Text(wallet.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), overflow: TextOverflow.ellipsis)),
],
),
),
IconButton(
icon: const Icon(LucideIcons.edit3, size: 18),
onPressed: () => _showSetBudgetDialog(context, wallet, budget),
)
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(progressColor),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Spent: Rs. ${spent.toStringAsFixed(2)}', style: TextStyle(color: Colors.grey.shade700)),
Text('Limit: Rs. ${limit.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
if (progress >= 1.0)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text('Budget Exceeded!', style: TextStyle(color: Colors.red.shade700, fontSize: 12, fontWeight: FontWeight.bold)),
),
],
),
),
);
}),
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,881 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart';
import '../../transactions/data/repository.dart';
import 'wallet_ledger_screen.dart';
class AccountsScreen extends ConsumerStatefulWidget {
final String? initialFilterNature;
const AccountsScreen({super.key, this.initialFilterNature});
@override
ConsumerState<AccountsScreen> createState() => _AccountsScreenState();
}
class _AccountsScreenState extends ConsumerState<AccountsScreen> {
String _searchQuery = '';
String? _filterNature;
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_filterNature = widget.initialFilterNature;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _onRefresh() async {
ref.invalidate(walletProvider);
ref.invalidate(invitationProvider);
await Future.delayed(const Duration(milliseconds: 500));
}
void _showCreateWalletDialog() {
final ctrl = TextEditingController();
final amtCtrl = TextEditingController();
DateTime openingDate = DateTime.now();
String selectedNature = 'CASH';
final natures = ['CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setStateDialog) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF6C63FF).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.wallet, color: Color(0xFF6C63FF), size: 24),
),
const SizedBox(width: 16),
const Text(
'New Account',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 24),
TextField(
controller: ctrl,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Account Name (e.g. Household)',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: selectedNature,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87),
decoration: InputDecoration(
labelText: 'Account Nature',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => selectedNature = val);
},
),
const SizedBox(height: 16),
TextField(
controller: amtCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Opening Balance (Optional)',
prefixText: 'Rs. ',
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: openingDate,
firstDate: DateTime.now().subtract(const Duration(days: 365 * 10)),
lastDate: DateTime.now(),
);
if (picked != null) {
setStateDialog(() => openingDate = picked);
}
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Icon(LucideIcons.calendar, color: Colors.grey),
const SizedBox(width: 12),
Text(
'Date: ${DateFormat('MMM dd, yyyy').format(openingDate)}',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
),
],
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(
foregroundColor: Colors.grey.shade700,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () async {
if (ctrl.text.isNotEmpty) {
final double amt = double.tryParse(amtCtrl.text) ?? 0.0;
final newWallet = await ref.read(walletProvider.notifier).createWallet(
name: ctrl.text,
nature: selectedNature,
initialBalance: 0.0,
);
if (amt > 0) {
final tx = Transaction(
id: 0,
type: 'INCOME',
amount: amt,
date: openingDate,
description: 'Opening Balance',
toWalletId: newWallet.id,
);
await ref.read(transactionProvider.notifier).addTransaction(tx);
}
if (context.mounted) Navigator.pop(ctx);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Create', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
),
);
},
),
);
}
void _showFilterSheet() {
final natures = ['ALL', 'CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
String? tempFilterNature = _filterNature;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => StatefulBuilder(
builder: (context, setSheetState) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Filter Accounts', style: Theme.of(context).textTheme.titleLarge),
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(ctx)),
],
),
const SizedBox(height: 24),
Text('Account Nature', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: tempFilterNature ?? 'ALL',
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16),
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) {
setSheetState(() {
tempFilterNature = val == 'ALL' ? null : val;
});
}
},
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
setState(() => _filterNature = null);
Navigator.pop(ctx);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
side: BorderSide(color: Colors.grey.shade300),
),
child: Text('Reset', style: TextStyle(color: Colors.grey.shade700, fontWeight: FontWeight.bold)),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
setState(() => _filterNature = tempFilterNature);
Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Apply Filter', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
),
const SizedBox(height: 16),
],
),
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
final walletsState = ref.watch(walletProvider);
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
bottom: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Row(
children: [
if (Navigator.of(context).canPop())
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.pop(context),
),
),
Expanded(
child: TextField(
controller: _searchController,
onChanged: (val) => setState(() => _searchQuery = val),
decoration: InputDecoration(
hintText: 'Search accounts...',
prefixIcon: const Icon(LucideIcons.search),
suffixIcon: _searchController.text.isNotEmpty ? IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
) : null,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
LucideIcons.filter,
color: _filterNature != null ? const Color(0xFF6C63FF) : null,
),
onPressed: _showFilterSheet,
),
IconButton(
icon: const Icon(LucideIcons.plusCircle),
onPressed: _showCreateWalletDialog,
),
],
),
),
Expanded(
child: walletsState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('Error: $e')),
data: (allWallets) {
final wallets = allWallets.where((w) {
final matchSearch = _searchQuery.isEmpty ||
w.name.toLowerCase().contains(_searchQuery.toLowerCase());
final matchNature = _filterNature == null || w.nature == _filterNature;
return matchSearch && matchNature;
}).toList();
if (wallets.isEmpty) {
return const Center(child: Text('No accounts found.'));
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: wallets.length,
itemBuilder: (context, index) {
final w = wallets[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: Colors.grey.shade200)),
elevation: 0,
child: Column(
children: [
InkWell(
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => WalletLedgerScreen(wallet: w)));
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
CircleAvatar(
backgroundColor: Colors.blue.withValues(alpha: 0.1),
child: const Icon(LucideIcons.wallet, color: Colors.blue),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(w.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 4),
Text('${w.nature ?? "CASH"} • Balance: Rs. ${w.balance}', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
],
),
),
],
),
),
),
Divider(height: 1, color: Colors.grey.shade200),
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
icon: const Icon(LucideIcons.userPlus, size: 14),
label: const Text('Invite', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () {
showDialog(
context: context,
builder: (ctx) => InviteDialogWidget(wallet: w),
);
},
),
TextButton.icon(
icon: const Icon(LucideIcons.edit2, size: 14),
label: const Text('Edit', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () {
final editCtrl = TextEditingController(text: w.name);
String editNature = w.nature ?? 'CASH';
final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME'];
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setStateDialog) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Edit Account', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: editCtrl,
decoration: InputDecoration(
hintText: 'Account Name',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: editNature,
decoration: InputDecoration(
labelText: 'Account Nature',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => editNature = val);
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () async {
if (editCtrl.text.isNotEmpty) {
try {
await ref.read(walletProvider.notifier).editWallet(
w.id,
name: editCtrl.text.trim(),
nature: editNature,
);
if (context.mounted) {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Wallet updated')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to edit: $e')));
}
}
}
},
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C63FF), foregroundColor: Colors.white),
child: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
),
),
);
},
),
TextButton.icon(
icon: const Icon(LucideIcons.trash2, size: 14, color: Colors.red),
label: const Text('Delete', style: TextStyle(color: Colors.red, fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Delete Account', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.red)),
const SizedBox(height: 16),
Text('Are you sure you want to delete ${w.name}? This action cannot be undone.', style: const TextStyle(fontSize: 16)),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
child: const Text('Delete', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
);
if (confirm == true) {
try {
await ref.read(walletProvider.notifier).deleteWallet(w.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Wallet deleted')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
}
}
}
},
),
TextButton.icon(
icon: const Icon(LucideIcons.list, size: 14),
label: const Text('Ledger', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => WalletLedgerScreen(wallet: w)));
},
),
],
),
), // Close FittedBox
],
),
);
},
),
);
},
),
),
],
),
),
);
}
}
class InviteDialogWidget extends ConsumerStatefulWidget {
final Wallet wallet;
const InviteDialogWidget({super.key, required this.wallet});
@override
ConsumerState<InviteDialogWidget> createState() => _InviteDialogWidgetState();
}
class _InviteDialogWidgetState extends ConsumerState<InviteDialogWidget> {
TextEditingController _autoCompleteCtrl = TextEditingController();
bool _isInviting = false;
bool _isLoadingMembers = true;
List<WalletMember> _members = [];
List<String> _knownContacts = [];
@override
void initState() {
super.initState();
_fetchMembers();
}
@override
void dispose() {
_autoCompleteCtrl.dispose();
super.dispose();
}
Future<void> _fetchMembers() async {
try {
final members = await ApiRepository().getWalletMembers(widget.wallet.id);
List<String> contacts = [];
try {
contacts = await ApiRepository().getKnownContacts();
} catch (e) {
debugPrint("Error fetching contacts: $e");
}
if (mounted) {
setState(() {
_members = members;
_knownContacts = contacts.where((c) => !members.any((m) => m.email == c)).toList();
_isLoadingMembers = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoadingMembers = false);
}
}
}
Future<void> _removeMember(WalletMember member) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove Member'),
content: Text('Are you sure you want to remove ${member.email} from this wallet?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
child: const Text('Remove'),
),
],
),
);
if (confirm == true) {
try {
await ApiRepository().removeWalletMember(widget.wallet.id, member.userId);
await _fetchMembers();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Member removed.')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
}
}
}
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Invite to ${widget.wallet.name}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: _autoCompleteCtrl,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
onChanged: (val) {
setState(() {});
},
decoration: InputDecoration(
hintText: 'Enter Email Address to invite',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (_autoCompleteCtrl?.text.trim().isNotEmpty == true && (_autoCompleteCtrl?.text.length ?? 0) >= 2)
Builder(
builder: (context) {
final query = _autoCompleteCtrl.text.trim().toLowerCase();
final matches = _knownContacts.where((c) => c.toLowerCase().contains(query)).toList();
if (matches.isEmpty || (matches.length == 1 && matches.first.toLowerCase() == query)) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.only(top: 8),
constraints: const BoxConstraints(maxHeight: 160),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
),
child: ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: matches.length,
separatorBuilder: (_, __) => const Divider(height: 1, indent: 16, endIndent: 16),
itemBuilder: (ctx, index) {
final email = matches[index];
return ListTile(
leading: const CircleAvatar(radius: 14, backgroundColor: Color(0xFF6C63FF), child: Icon(LucideIcons.user, size: 14, color: Colors.white)),
title: Text(email, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
onTap: () {
setState(() {
_autoCompleteCtrl.text = email;
_autoCompleteCtrl.selection = TextSelection.fromPosition(TextPosition(offset: email.length));
});
FocusScope.of(context).unfocus();
},
);
},
),
);
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isInviting ? null : () => Navigator.pop(context),
style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _isInviting ? null : () async {
final email = _autoCompleteCtrl.text.trim();
if (email.isNotEmpty && email.contains('@')) {
setState(() => _isInviting = true);
try {
await ref.read(walletProvider.notifier).inviteUser(widget.wallet.id, email);
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('User invited!')));
}
} catch (e) {
if (context.mounted) {
setState(() => _isInviting = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
}
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: _isInviting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Invite', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
const SizedBox(height: 24),
const Text('Existing Members', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_isLoadingMembers
? const Center(child: CircularProgressIndicator())
: _members.isEmpty
? const Text('No members found.', style: TextStyle(color: Colors.grey))
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _members.length,
itemBuilder: (context, index) {
final member = _members[index];
final isOwner = member.role == 'OWNER';
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(backgroundColor: Colors.grey.shade200, child: const Icon(LucideIcons.user, color: Colors.grey)),
title: Text(member.email, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
subtitle: Text(member.role, style: TextStyle(color: isOwner ? Colors.blue : Colors.grey, fontSize: 12)),
trailing: !isOwner
? IconButton(
icon: const Icon(LucideIcons.userMinus, color: Colors.red, size: 20),
onPressed: () => _removeMember(member),
)
: null,
);
},
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,604 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../transactions/presentation/add_transaction_screen.dart';
import '../../transactions/presentation/all_transactions_screen.dart';
import '../../transactions/providers/providers.dart';
import '../../auth/presentation/profile_screen.dart';
import '../../transactions/data/models.dart';
import '../../budget/presentation/budget_screen.dart';
import '../providers/insight_provider.dart';
import 'widgets/budget_status_card.dart';
import 'widgets/statistics_tab.dart';
import '../../../core/widgets/shimmer_loading.dart';
import '../../../core/theme/nature_colors.dart';
import 'wallet_ledger_screen.dart';
import 'maturity_dialog.dart';
import 'accounts_screen.dart';
class DashboardScreen extends ConsumerStatefulWidget {
const DashboardScreen({super.key});
@override
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
int _currentIndex = 0;
String _selectedFilter = 'All Time';
DateTimeRange? _customDateRange;
Timer? _notificationTimer;
final List<String> _filters = ['Today', 'Last 3 Days', 'Week', 'Month', 'Year', 'All Time', 'Custom'];
@override
void initState() {
super.initState();
_notificationTimer = Timer.periodic(const Duration(minutes: 2), (_) {
ref.invalidate(invitationProvider);
});
}
@override
void dispose() {
_notificationTimer?.cancel();
super.dispose();
}
Future<void> _onRefresh() async {
ref.invalidate(transactionProvider);
ref.invalidate(walletProvider);
ref.invalidate(categoryProvider);
ref.invalidate(budgetProvider);
ref.invalidate(invitationProvider);
await Future.delayed(const Duration(milliseconds: 500));
}
DateTimeRange _getDateRange(List<Transaction> all) {
if (_selectedFilter == 'All Time') {
DateTime start = DateTime(2000);
if (all.isNotEmpty) {
start = all.map((t) => t.date).reduce((a, b) => a.isBefore(b) ? a : b);
}
return DateTimeRange(start: start, end: DateTime.now());
}
final now = DateTime.now();
DateTime start;
DateTime end = now;
switch (_selectedFilter) {
case 'Today':
start = DateTime(now.year, now.month, now.day);
break;
case 'Last 3 Days':
start = now.subtract(const Duration(days: 3));
break;
case 'Week':
start = now.subtract(const Duration(days: 7));
break;
case 'Month':
start = DateTime(now.year, now.month - 1, now.day);
break;
case 'Year':
start = DateTime(now.year - 1, now.month, now.day);
break;
case 'Custom':
if (_customDateRange != null) {
start = _customDateRange!.start;
end = _customDateRange!.end;
} else {
start = DateTime(2000);
if (all.isNotEmpty) {
start = all.map((t) => t.date).reduce((a, b) => a.isBefore(b) ? a : b);
}
}
break;
default:
start = DateTime(2000);
}
return DateTimeRange(start: start, end: end);
}
List<Transaction> _filterTransactions(List<Transaction> all) {
if (_selectedFilter == 'All Time' || (_selectedFilter == 'Custom' && _customDateRange == null)) return all;
final range = _getDateRange(all);
final start = range.start;
final end = range.end;
return all.where((t) {
final d = t.date;
return d.isAfter(start.subtract(const Duration(seconds: 1))) &&
d.isBefore(end.add(const Duration(days: 1)));
}).toList();
}
Future<void> _pickCustomDateRange() async {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime(2101),
);
if (picked != null) {
setState(() {
_customDateRange = picked;
_selectedFilter = 'Custom';
});
}
}
@override
Widget build(BuildContext context) {
final transState = ref.watch(transactionProvider);
final categoriesState = ref.watch(categoryProvider);
final insight = ref.watch(insightProvider);
final walletsState = ref.watch(walletProvider);
final allTransactions = transState.value ?? [];
final range = _getDateRange(allTransactions);
final startDate = range.start;
final endDate = range.end;
String title;
if (_currentIndex == 0) title = 'Dashboard';
else if (_currentIndex == 1) title = 'Statistics';
else if (_currentIndex == 2) title = 'My Accounts';
else title = 'Budgets';
return Scaffold(
appBar: AppBar(
title: Text(title),
backgroundColor: Colors.transparent,
elevation: 0,
actions: [
Consumer(
builder: (context, ref, child) {
final invitationsState = ref.watch(invitationProvider);
final pendingInvites = invitationsState.value?.where((inv) => inv.status == 'PENDING').toList() ?? [];
return PopupMenuButton<String>(
icon: Stack(
children: [
const Icon(LucideIcons.bell),
if (pendingInvites.isNotEmpty)
Positioned(
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(6),
),
constraints: const BoxConstraints(minWidth: 12, minHeight: 12),
child: Text(
'${pendingInvites.length}',
style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
)
],
),
offset: const Offset(0, 50),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
itemBuilder: (context) {
if (pendingInvites.isEmpty) {
return [
const PopupMenuItem(
enabled: false,
child: Text('No new notifications'),
)
];
}
return pendingInvites.map((inv) => PopupMenuItem<String>(
enabled: false,
child: Container(
width: 250,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('You have been invited to a wallet by User ${inv.inviterId}', style: const TextStyle(fontSize: 14)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
Navigator.pop(context);
ref.read(invitationProvider.notifier).rejectInvitation(inv.id);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Reject'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
ref.read(invitationProvider.notifier).acceptInvitation(inv.id);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Accept'),
),
],
)
],
),
),
)).toList();
},
);
}
),
IconButton(
icon: const Icon(LucideIcons.user),
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProfileScreen()));
},
),
],
),
body: transState.when(
loading: () => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 4,
itemBuilder: (context, index) => const ShimmerCard(),
),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (allTransactions) {
final transactions = _filterTransactions(allTransactions);
final totalIncome = transactions.where((t) => t.type == 'INCOME').fold(0.0, (s, t) => s + t.amount);
final totalExpense = transactions.where((t) => t.type == 'EXPENSE').fold(0.0, (s, t) => s + t.amount);
final totalInvestment = transactions.where((t) => t.type == 'INVESTMENT').fold(0.0, (s, t) => s + t.amount);
double totalPayablesPeriod = 0.0;
double totalReceivablesPeriod = 0.0;
final safeWallets = walletsState.hasValue ? walletsState.value! : <Wallet>[];
for (var t in transactions) {
if (t.toWalletId != null) {
final w = safeWallets.where((w) => w.id == t.toWalletId);
if (w.isNotEmpty) {
final nature = w.first.nature;
if (nature == 'PAYABLES' || nature == 'LOAN') {
totalPayablesPeriod += t.amount;
} else if (nature == 'RECEIVABLES' || nature == 'LENDING') {
totalReceivablesPeriod += t.amount;
}
}
}
}
double cashBalance = 0;
double expenseBalance = 0;
double savingsBalance = 0;
double investmentsBalance = 0;
double payablesBalance = 0;
double receivablesBalance = 0;
if (walletsState.hasValue) {
for (var w in walletsState.value!) {
final nature = w.nature ?? 'CASH';
if (nature == 'CASH') {
cashBalance += w.balance;
} else if (nature == 'EXPENSE') {
expenseBalance += w.balance;
} else if (nature == 'SAVINGS' || nature == 'INCOME') {
savingsBalance += w.balance;
} else if (nature == 'INVESTMENTS') {
investmentsBalance += w.balance;
} else if (nature == 'LOAN' || nature == 'PAYABLES') {
payablesBalance += w.balance;
} else if (nature == 'LENDING') {
receivablesBalance += w.balance;
}
}
}
return IndexedStack(
index: _currentIndex,
children: [
// ---------------- HOME TAB ----------------
RefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (insight != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: insight.type == 'WARNING' ? Colors.orange.shade50 : (insight.type == 'SUCCESS' ? Colors.green.shade50 : Colors.blue.shade50),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: insight.type == 'WARNING' ? Colors.orange : (insight.type == 'SUCCESS' ? Colors.green : Colors.blue), width: 1),
),
child: Row(
children: [
Icon(
insight.type == 'WARNING' ? LucideIcons.alertTriangle : (insight.type == 'SUCCESS' ? LucideIcons.checkCircle : LucideIcons.info),
color: insight.type == 'WARNING' ? Colors.orange : (insight.type == 'SUCCESS' ? Colors.green : Colors.blue),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(insight.title, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(insight.message, style: const TextStyle(fontSize: 12)),
],
),
),
],
),
),
const SizedBox(height: 24),
],
const BudgetStatusCard(),
const SizedBox(height: 24),
// Summary Cards Grid
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.5,
children: [
_buildSummaryCard(context, 'Balance', cashBalance, NatureColors.getColor('BALANCE'), LucideIcons.wallet, 'CASH'),
_buildSummaryCard(context, 'Expense', expenseBalance, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUpRight, 'EXPENSE'),
_buildSummaryCard(context, 'Savings', savingsBalance, NatureColors.getColor('SAVINGS'), LucideIcons.piggyBank, 'SAVINGS'),
_buildSummaryCard(context, 'Investments', investmentsBalance, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp, 'INVESTMENTS'),
_buildSummaryCard(context, 'Payables', payablesBalance, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle, 'PAYABLES'),
_buildSummaryCard(context, 'Receivables', receivablesBalance, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft, 'RECEIVABLES'),
],
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Recent Transactions', style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold)),
TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AllTransactionsScreen()));
},
child: const Text('See All'),
),
],
),
const SizedBox(height: 8),
allTransactions.isEmpty
? const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('No transactions yet!')))
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: allTransactions.length > 5 ? 5 : allTransactions.length,
itemBuilder: (context, index) {
final t = allTransactions[index];
final isIncome = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null);
final isInvestment = t.type == 'INVESTMENT';
final isExpense = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null);
final isTransfer = t.type == 'TRANSFER' && t.fromWalletId != null && t.toWalletId != null;
Color typeColor = Colors.grey;
IconData typeIcon = LucideIcons.arrowRightLeft;
if (isIncome) {
typeColor = NatureColors.getColor('INCOME');
typeIcon = LucideIcons.arrowDownCircle;
} else if (isExpense) {
typeColor = NatureColors.getColor('EXPENSE');
typeIcon = LucideIcons.arrowUpCircle;
} else if (isInvestment) {
typeColor = NatureColors.getColor('INVESTMENTS');
typeIcon = LucideIcons.trendingUp;
} else if (isTransfer) {
typeColor = NatureColors.getColor('TRANSFER');
if (walletsState.hasValue) {
final toW = walletsState.value!.where((w) => w.id == t.toWalletId).firstOrNull;
final fromW = walletsState.value!.where((w) => w.id == t.fromWalletId).firstOrNull;
if (toW != null && (toW.nature == 'PAYABLES' || toW.nature == 'LOAN')) {
typeColor = NatureColors.getColor('PAYABLES');
typeIcon = LucideIcons.alertCircle;
} else if (toW != null && (toW.nature == 'RECEIVABLES' || toW.nature == 'LENDING')) {
typeColor = NatureColors.getColor('RECEIVABLES');
typeIcon = LucideIcons.arrowDownLeft;
} else if (fromW != null && (fromW.nature == 'PAYABLES' || fromW.nature == 'LOAN')) {
typeColor = NatureColors.getColor('PAYABLES');
typeIcon = LucideIcons.alertCircle;
} else if (fromW != null && (fromW.nature == 'RECEIVABLES' || fromW.nature == 'LENDING')) {
typeColor = NatureColors.getColor('RECEIVABLES');
typeIcon = LucideIcons.arrowDownLeft;
}
}
}
String categoryName = 'Unknown';
if (categoriesState.hasValue) {
final match = categoriesState.value!.where((c) => c.id == t.categoryId);
if (match.isNotEmpty) categoryName = match.first.name;
}
String accountName = 'Unknown';
if (walletsState.hasValue) {
if (isExpense && t.toWalletId != null) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) accountName = match.first.name;
} else if (isIncome && (t.toWalletId != null)) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) accountName = match.first.name;
} else if (isTransfer && t.toWalletId != null) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) accountName = 'To ${match.first.name}';
}
}
String fromName = '';
if (t.fromWalletId != null && walletsState.hasValue) {
final match = walletsState.value!.where((w) => w.id == t.fromWalletId);
if (match.isNotEmpty) fromName = match.first.name;
}
String toName = '';
if (t.toWalletId != null && walletsState.hasValue) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) toName = match.first.name;
}
String subtitleText = '';
if (categoryName == 'Unknown' && fromName.isNotEmpty && toName.isNotEmpty) {
subtitleText = '$fromName$toName${DateFormat('MMM dd, yyyy').format(t.date)}';
} else if (categoryName == 'Unknown' && toName.isNotEmpty) {
subtitleText = 'To $toName${DateFormat('MMM dd, yyyy').format(t.date)}';
} else if (categoryName == 'Unknown' && fromName.isNotEmpty) {
subtitleText = 'From $fromName${DateFormat('MMM dd, yyyy').format(t.date)}';
} else {
subtitleText = '$categoryName${DateFormat('MMM dd, yyyy').format(t.date)}${fromName.isNotEmpty ? ' • 💼 $fromName' : ''}';
}
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => AddTransactionScreen(transaction: t)));
},
leading: CircleAvatar(
backgroundColor: typeColor.withOpacity(0.1),
child: Icon(typeIcon, color: typeColor),
),
title: Text((t.description != null && t.description!.isNotEmpty) ? t.description! : accountName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(subtitleText),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isIncome ? '+' : (isTransfer ? '' : '-')}Rs. ${t.amount.toStringAsFixed(0)}',
style: TextStyle(fontWeight: FontWeight.bold, color: typeColor),
),
if (t.investmentStatus == 'OPEN' && (typeColor == NatureColors.getColor('INVESTMENTS') || typeColor == NatureColors.getColor('RECEIVABLES')))
GestureDetector(
onTap: () {
showDialog(context: context, builder: (_) => MaturityDialog(transaction: t));
},
child: const Padding(
padding: EdgeInsets.only(top: 4.0),
child: Text('Close', style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 12)),
),
),
],
),
),
);
},
)
],
),
),
),
// ---------------- STATS TAB ----------------
StatisticsTab(
transactions: transactions,
wallets: safeWallets,
categories: categoriesState.value ?? [],
selectedFilter: _selectedFilter,
startDate: startDate,
endDate: endDate,
filters: _filters,
onFilterChanged: (val) {
setState(() => _selectedFilter = val);
},
onPickCustomDateRange: _pickCustomDateRange,
onRefresh: _onRefresh,
),
// ---------------- WALLETS TAB ----------------
const AccountsScreen(),
// ---------------- BUDGETS TAB ----------------
const BudgetScreen(),
],
);
},
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex >= 2 ? _currentIndex + 1 : _currentIndex,
type: BottomNavigationBarType.fixed,
onTap: (index) {
if (index == 2) {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
} else {
setState(() {
_currentIndex = index > 2 ? index - 1 : index;
});
}
},
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Colors.grey,
showSelectedLabels: true,
showUnselectedLabels: true,
items: const [
BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'),
BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
],
),
);
}
Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, [String? nature]) {
return GestureDetector(
onTap: nature != null ? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AccountsScreen(initialFilterNature: nature),
),
);
} : null,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withOpacity(0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 8),
Text(title, style: TextStyle(color: color, fontWeight: FontWeight.w600)),
],
),
Text(
'Rs. ${amount.toStringAsFixed(0)}',
style: TextStyle(color: color, fontSize: 20, fontWeight: FontWeight.bold),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../transactions/data/models.dart';
import '../../transactions/providers/providers.dart';
class MaturityDialog extends ConsumerStatefulWidget {
final Transaction transaction;
const MaturityDialog({super.key, required this.transaction});
@override
ConsumerState<MaturityDialog> createState() => _MaturityDialogState();
}
class _MaturityDialogState extends ConsumerState<MaturityDialog> {
final _amountController = TextEditingController();
DateTime _maturityDate = DateTime.now();
Wallet? _toWallet;
@override
void initState() {
super.initState();
_amountController.text = widget.transaction.amount.toStringAsFixed(2);
}
void _pickDate() async {
final date = await showDatePicker(
context: context,
initialDate: _maturityDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: Color(0xFF6C63FF),
onPrimary: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (date != null) {
setState(() => _maturityDate = date);
}
}
@override
Widget build(BuildContext context) {
final walletsState = ref.watch(walletProvider);
List<Wallet> validWallets = [];
if (walletsState.hasValue) {
validWallets = walletsState.value!.where((w) => w.nature == 'CASH' || w.nature == 'SAVINGS').toList();
if (_toWallet == null && validWallets.length == 1) {
_toWallet = validWallets.first;
}
}
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF6C63FF).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.checkCircle, color: Color(0xFF6C63FF)),
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'Close Investment',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF1E1E2D),
),
),
),
],
),
const SizedBox(height: 24),
// Original Investment Details Card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Original Investment',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(
widget.transaction.description ?? 'Investment',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'Rs. ${widget.transaction.amount.toStringAsFixed(2)}',
style: const TextStyle(color: Color(0xFF6C63FF), fontWeight: FontWeight.bold),
),
],
),
),
const SizedBox(height: 24),
// Maturity Amount Field
Text(
'Maturity Amount',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey.shade700),
),
const SizedBox(height: 8),
TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
textInputAction: TextInputAction.done,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
decoration: InputDecoration(
prefixText: 'Rs. ',
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
),
const SizedBox(height: 20),
// Date Picker Field
Text(
'Closure Date',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey.shade700),
),
const SizedBox(height: 8),
InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(LucideIcons.calendar, color: Colors.grey.shade600, size: 20),
const SizedBox(width: 12),
Text(
DateFormat('dd MMM yyyy').format(_maturityDate),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
],
),
),
),
const SizedBox(height: 20),
// To Account Dropdown
Text(
'Deposit To (Savings/Cash)',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey.shade700),
),
const SizedBox(height: 8),
DropdownButtonFormField<Wallet>(
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
icon: Icon(LucideIcons.chevronDown, color: Colors.grey.shade600),
value: _toWallet,
hint: const Text('Select an account'),
items: validWallets.map((w) {
return DropdownMenuItem(
value: w,
child: Text(w.name, style: const TextStyle(fontWeight: FontWeight.w500)),
);
}).toList(),
onChanged: (val) {
setState(() => _toWallet = val);
},
),
const SizedBox(height: 32),
// Action Buttons
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: Text(
'Cancel',
style: TextStyle(color: Colors.grey.shade600, fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () async {
final amount = double.tryParse(_amountController.text);
if (amount == null || amount <= 0) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter a valid amount')));
return;
}
if (_toWallet == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Select a To Account')));
return;
}
try {
await ref.read(transactionProvider.notifier).closeInvestment(
widget.transaction.id,
amount,
_maturityDate,
_toWallet!.id
);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Closure processed successfully!'),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Error: $e'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
));
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text(
'Confirm',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
],
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart';
import '../../../core/theme/nature_colors.dart';
class WalletLedgerScreen extends ConsumerStatefulWidget {
final Wallet wallet;
const WalletLedgerScreen({super.key, required this.wallet});
@override
ConsumerState<WalletLedgerScreen> createState() => _WalletLedgerScreenState();
}
class _WalletLedgerScreenState extends ConsumerState<WalletLedgerScreen> {
String _selectedFilter = 'All Time';
DateTimeRange? _customDateRange;
void _showFilterSheet() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Filter Ledger', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
ListTile(
title: const Text('Today'),
onTap: () {
setState(() => _selectedFilter = 'Today');
Navigator.pop(ctx);
},
),
ListTile(
title: const Text('Current Month'),
onTap: () {
setState(() => _selectedFilter = 'Current Month');
Navigator.pop(ctx);
},
),
ListTile(
title: const Text('All Time'),
onTap: () {
setState(() => _selectedFilter = 'All Time');
Navigator.pop(ctx);
},
),
ListTile(
title: const Text('Custom Range'),
onTap: () async {
Navigator.pop(ctx);
final range = await showDateRangePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (range != null) {
setState(() {
_selectedFilter = 'Custom Range';
_customDateRange = range;
});
}
},
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
final transactionsState = ref.watch(transactionProvider);
final walletsState = ref.watch(walletProvider);
return Scaffold(
appBar: AppBar(
title: Text('${widget.wallet.name} Ledger'),
actions: [
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: _showFilterSheet,
),
],
),
body: transactionsState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (transactions) {
// 1. Filter all transactions related to this wallet
final walletTxs = transactions.where((t) => t.fromWalletId == widget.wallet.id || t.toWalletId == widget.wallet.id).toList();
// Sort chronologically (oldest first) to compute running balance
walletTxs.sort((a, b) => a.date.compareTo(b.date));
// 2. Determine date range
DateTime? startDate;
DateTime? endDate;
final now = DateTime.now();
if (_selectedFilter == 'Today') {
startDate = DateTime(now.year, now.month, now.day);
endDate = DateTime(now.year, now.month, now.day, 23, 59, 59);
} else if (_selectedFilter == 'Current Month') {
startDate = DateTime(now.year, now.month, 1);
endDate = DateTime(now.year, now.month + 1, 0, 23, 59, 59);
} else if (_selectedFilter == 'Custom Range' && _customDateRange != null) {
startDate = _customDateRange!.start;
endDate = DateTime(_customDateRange!.end.year, _customDateRange!.end.month, _customDateRange!.end.day, 23, 59, 59);
}
// 3. Calculate opening balance (sum of all transactions BEFORE start date)
double openingBalance = 0.0;
List<Transaction> visibleTxs = [];
for (var t in walletTxs) {
double impact = 0;
if (t.toWalletId == widget.wallet.id) {
impact = t.amount;
} else if (t.fromWalletId == widget.wallet.id) {
impact = -t.amount;
}
if (startDate != null && t.date.isBefore(startDate)) {
openingBalance += impact;
} else if (endDate != null && t.date.isAfter(endDate)) {
// skip
} else {
visibleTxs.add(t);
}
}
// 4. Generate Ledger Rows
double runningBalance = openingBalance;
List<DataRow> rows = [];
// Add Opening Balance Row if we have a date filter
if (startDate != null) {
rows.add(DataRow(
cells: [
const DataCell(Text('-')),
DataCell(Text(DateFormat('dd MMM yyyy').format(startDate))),
DataCell(Text('Rs. ${openingBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))),
const DataCell(Text('-')),
const DataCell(Text('-')),
DataCell(Text('Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))),
],
));
}
for (int i = 0; i < visibleTxs.length; i++) {
final t = visibleTxs[i];
bool isIncome = t.toWalletId == widget.wallet.id && t.fromWalletId == null;
bool isExpense = t.fromWalletId == widget.wallet.id && t.toWalletId == null;
bool isTransferIn = t.toWalletId == widget.wallet.id && t.fromWalletId != null;
bool isTransferOut = t.fromWalletId == widget.wallet.id && t.toWalletId != null;
double amount = t.amount;
if (isExpense || isTransferOut) {
runningBalance -= amount;
} else {
runningBalance += amount;
}
String fromName = '-';
String toName = '-';
if (isIncome || isTransferIn) {
if (t.fromWalletId != null && walletsState.hasValue) {
fromName = walletsState.value!.where((w) => w.id == t.fromWalletId).firstOrNull?.name ?? 'Unknown';
} else {
fromName = 'External';
}
toName = widget.wallet.name;
} else if (isExpense || isTransferOut) {
fromName = widget.wallet.name;
if (t.toWalletId != null && walletsState.hasValue) {
toName = walletsState.value!.where((w) => w.id == t.toWalletId).firstOrNull?.name ?? 'Unknown';
} else {
toName = 'External';
}
}
Color amountColor = (isExpense || isTransferOut) ? Colors.red : Colors.green.shade700;
String amountPrefix = (isExpense || isTransferOut) ? '-' : '+';
rows.add(DataRow(
cells: [
DataCell(Text('${startDate != null ? i + 1 : i + 1}')),
DataCell(Text(DateFormat('dd MMM yyyy').format(t.date))),
DataCell(Text('$amountPrefix Rs. ${amount.toStringAsFixed(2)}', style: TextStyle(color: amountColor, fontWeight: FontWeight.w600))),
DataCell(Text(fromName)),
DataCell(Text(toName)),
DataCell(Text('Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))),
],
));
}
if (rows.isEmpty) {
return const Center(child: Text('No transactions found for this period.'));
}
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Filter: $_selectedFilter', style: TextStyle(color: Colors.grey.shade600)),
Text('Closing Balance: Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
],
),
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100),
columnSpacing: 24,
columns: const [
DataColumn(label: Text('S.No', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('From', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('To', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Balance', style: TextStyle(fontWeight: FontWeight.bold))),
],
rows: rows,
),
),
),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../transactions/providers/providers.dart';
class BudgetStatusCard extends ConsumerWidget {
const BudgetStatusCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final transState = ref.watch(transactionProvider);
final budgetState = ref.watch(budgetProvider);
if (transState.isLoading || budgetState.isLoading) {
return const CircularProgressIndicator();
}
final transactions = transState.value ?? [];
final budgets = budgetState.value ?? [];
if (budgets.isEmpty) {
return const SizedBox.shrink(); // Hide if no budgets
}
final now = DateTime.now();
final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year);
double totalSpent = 0;
for (var b in budgets) {
if (b.walletId != null) {
final spentForWallet = currentMonthTxs.where((t) => t.toWalletId == b.walletId).fold(0.0, (s, t) => s + t.amount);
totalSpent += spentForWallet;
}
}
final totalLimit = budgets.fold(0.0, (s, b) => s + b.monthlyLimit);
final progress = totalLimit > 0 ? (totalSpent / totalLimit).clamp(0.0, 1.0) : 0.0;
Color progressColor = Colors.green;
if (progress > 0.9) progressColor = Colors.red;
else if (progress > 0.7) progressColor = Colors.orange;
return Card(
child: InkWell(
onTap: () {
// Could navigate to budget tab or push budget screen
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(LucideIcons.target, size: 18),
SizedBox(width: 8),
Text('Overall Budget Status', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.shade200,
color: progressColor,
minHeight: 12,
borderRadius: BorderRadius.circular(6),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Spent: Rs. ${totalSpent.toStringAsFixed(0)}', style: TextStyle(color: Colors.grey.shade700)),
Text('Limit: Rs. ${totalLimit.toStringAsFixed(0)}', style: const TextStyle(fontWeight: FontWeight.bold)),
],
)
],
),
),
),
);
}
}

View File

@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../transactions/data/models.dart';
import '../../../../core/theme/nature_colors.dart';
class CategorySpendingChart extends StatefulWidget {
final List<Transaction> transactions;
final List<Category> categories;
final String selectedNature;
const CategorySpendingChart({
super.key,
required this.transactions,
required this.categories,
required this.selectedNature,
});
@override
State<CategorySpendingChart> createState() => _CategorySpendingChartState();
}
class _CategorySpendingChartState extends State<CategorySpendingChart> {
int touchedIndex = -1;
@override
Widget build(BuildContext context) {
final Map<int, double> categoryTotals = {};
for (var t in widget.transactions) {
bool matches = false;
if (widget.selectedNature == 'INCOME') matches = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null);
else if (widget.selectedNature == 'EXPENSE') matches = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null);
else if (widget.selectedNature == 'INVESTMENTS') matches = t.type == 'INVESTMENT';
if (matches) {
final catId = t.categoryId ?? -1;
categoryTotals[catId] = (categoryTotals[catId] ?? 0) + t.amount;
}
}
if (categoryTotals.isEmpty) {
return const SizedBox();
}
final List<MapEntry<int, double>> sortedTotals = categoryTotals.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final List<Color> colors = NatureColors.getPalette(widget.selectedNature);
double totalAmount = categoryTotals.values.fold(0.0, (s, e) => s + e);
List<PieChartSectionData> sections = [];
for (int i = 0; i < sortedTotals.length; i++) {
final isTouched = i == touchedIndex;
final radius = isTouched ? 45.0 : 35.0;
final fontSize = isTouched ? 16.0 : 12.0;
final amount = sortedTotals[i].value;
final percentage = (amount / totalAmount * 100).toStringAsFixed(1);
sections.add(
PieChartSectionData(
showTitle: false,
color: colors[i % colors.length],
value: amount,
title: '$percentage%',
radius: radius,
titleStyle: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: const [Shadow(color: Colors.black26, blurRadius: 2)],
),
badgeWidget: isTouched
? Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 4)],
),
child: Icon(Icons.touch_app, size: 16, color: colors[i % colors.length]),
)
: null,
badgePositionPercentageOffset: 1.1,
),
);
}
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32), side: BorderSide(color: Colors.grey.shade200)),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.white, Colors.grey.shade50],
),
),
padding: const EdgeInsets.all(28.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Category Breakdown', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
const SizedBox(height: 32),
SizedBox(
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions || pieTouchResponse == null || pieTouchResponse.touchedSection == null) {
touchedIndex = -1;
return;
}
touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
});
},
),
borderData: FlBorderData(show: false),
sectionsSpace: 4,
centerSpaceRadius: 65,
sections: sections,
),
),
// Center Text
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Total', style: TextStyle(fontSize: 14, color: Colors.grey.shade500, fontWeight: FontWeight.w600)),
Text('Rs. ${totalAmount.toStringAsFixed(0)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900)),
],
),
],
),
),
const SizedBox(height: 40),
const Text('Details', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.black87)),
const SizedBox(height: 16),
// Modern List for Legends
...List.generate(sortedTotals.length, (i) {
String categoryName = 'Uncategorized';
if (sortedTotals[i].key != -1) {
final match = widget.categories.where((c) => c.id == sortedTotals[i].key);
if (match.isNotEmpty) categoryName = match.first.name;
}
final amount = sortedTotals[i].value;
final percentage = (amount / totalAmount * 100).toStringAsFixed(1);
final isTouched = i == touchedIndex;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration(
color: isTouched ? colors[i % colors.length].withValues(alpha: 0.05) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isTouched ? colors[i % colors.length].withValues(alpha: 0.3) : Colors.grey.shade100,
width: isTouched ? 1.5 : 1.0,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isTouched ? 0.05 : 0.02),
blurRadius: isTouched ? 12 : 6,
offset: const Offset(0, 4),
)
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colors[i % colors.length].withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.category, color: colors[i % colors.length], size: 20),
),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(categoryName, style: TextStyle(fontWeight: FontWeight.w700, fontSize: 15, color: Colors.grey.shade800)),
const SizedBox(height: 4),
Text('Rs. ${amount.toStringAsFixed(0)}', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: Colors.grey.shade500)),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colors[i % colors.length].withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text('$percentage%', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 13, color: colors[i % colors.length])),
),
],
),
);
}),
],
),
),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../transactions/data/models.dart';
import '../../../../core/theme/nature_colors.dart';
class DayWiseSpendingChart extends StatelessWidget {
final List<Transaction> transactions;
final List<Wallet> wallets;
final String selectedNature;
final DateTime startDate;
final DateTime endDate;
const DayWiseSpendingChart({
super.key,
required this.transactions,
required this.wallets,
required this.selectedNature,
required this.startDate,
required this.endDate,
});
@override
Widget build(BuildContext context) {
// 1. Determine date range length
final int daysCount = endDate.difference(startDate).inDays + 1;
final int displayDays = daysCount > 0 && daysCount < 1000 ? daysCount : 30; // Guard against 'All Time' returning thousands
final days = List.generate(displayDays, (i) => startDate.add(Duration(days: i)));
// 2. Initialize daily totals
final Map<String, double> dailyTotals = {};
for (var d in days) {
dailyTotals["${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}"] = 0.0;
}
// 3. Populate totals
for (var t in transactions) {
final tDate = "${t.date.year}-${t.date.month.toString().padLeft(2, '0')}-${t.date.day.toString().padLeft(2, '0')}";
if (dailyTotals.containsKey(tDate)) {
bool matches = false;
if (selectedNature == 'INCOME') matches = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null);
else if (selectedNature == 'EXPENSE') matches = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null);
else if (selectedNature == 'INVESTMENTS') matches = t.type == 'INVESTMENT';
else if (selectedNature == 'PAYABLES' || selectedNature == 'RECEIVABLES') {
if (t.type == 'TRANSFER' && t.toWalletId != null) {
final w = wallets.where((w) => w.id == t.toWalletId);
if (w.isNotEmpty) {
final nature = w.first.nature;
if (selectedNature == 'PAYABLES' && (nature == 'PAYABLES' || nature == 'LOAN')) matches = true;
if (selectedNature == 'RECEIVABLES' && (nature == 'RECEIVABLES' || nature == 'LENDING')) matches = true;
}
}
}
if (matches) {
dailyTotals[tDate] = dailyTotals[tDate]! + t.amount;
}
}
}
double maxY = 0.0;
for (var total in dailyTotals.values) {
if (total > maxY) maxY = total;
}
if (maxY == 0) maxY = 100;
Color barColor = NatureColors.getColor(selectedNature);
// Make chart horizontally scrollable if there are many days
final screenWidth = MediaQuery.of(context).size.width;
double chartWidth = (screenWidth / 7) * displayDays;
if (chartWidth < screenWidth - 80) { // minimum width
chartWidth = screenWidth - 80;
}
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24), side: BorderSide(color: Colors.grey.shade200)),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Spending Trends', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 32),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
height: 200,
width: chartWidth,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxY * 1.2,
barTouchData: BarTouchData(enabled: false),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= days.length) return const SizedBox();
final date = days[index];
final text = "${date.day} ${_monthStr(date.month)}";
// To prevent overcrowding on large ranges
if (displayDays > 14 && index % 2 != 0) return const SizedBox();
if (displayDays > 30 && index % 5 != 0) return const SizedBox();
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(text, style: const TextStyle(color: Colors.grey, fontSize: 10, fontWeight: FontWeight.bold)),
);
},
reservedSize: 28,
),
),
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: maxY / 4 == 0 ? 1 : maxY / 4,
getDrawingHorizontalLine: (value) => FlLine(color: Colors.grey.shade200, strokeWidth: 1, dashArray: [4, 4]),
),
borderData: FlBorderData(show: false),
barGroups: dailyTotals.entries.toList().asMap().entries.map((entry) {
return BarChartGroupData(
x: entry.key,
barRods: [
BarChartRodData(
toY: entry.value.value,
color: barColor,
width: 16,
borderRadius: BorderRadius.circular(8),
backDrawRodData: BackgroundBarChartRodData(
show: true,
toY: maxY * 1.2,
color: Colors.grey.shade100,
),
),
],
);
}).toList(),
),
),
),
),
],
),
),
);
}
String _monthStr(int m) {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return months[m - 1];
}
}

View File

@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../transactions/data/models.dart';
import '../../../../core/theme/nature_colors.dart';
import 'day_wise_spending_chart.dart';
import 'category_spending_chart.dart';
class StatisticsTab extends StatefulWidget {
final List<Transaction> transactions;
final List<Wallet> wallets;
final List<Category> categories;
final String selectedFilter;
final DateTime startDate;
final DateTime endDate;
final VoidCallback onPickCustomDateRange;
final Function(String) onFilterChanged;
final Future<void> Function() onRefresh;
final List<String> filters;
const StatisticsTab({
super.key,
required this.transactions,
required this.wallets,
required this.categories,
required this.selectedFilter,
required this.startDate,
required this.endDate,
required this.onPickCustomDateRange,
required this.onFilterChanged,
required this.onRefresh,
required this.filters,
});
@override
State<StatisticsTab> createState() => _StatisticsTabState();
}
class _StatisticsTabState extends State<StatisticsTab> {
String _selectedNature = 'EXPENSE';
Widget _buildTotalCard(String title, double amount, Color color, IconData icon) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: Colors.grey.shade200)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
CircleAvatar(backgroundColor: color.withOpacity(0.1), radius: 16, child: Icon(icon, color: color, size: 16)),
const SizedBox(width: 8),
Expanded(child: Text(title, style: const TextStyle(fontSize: 12, color: Colors.grey, fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis)),
],
),
const SizedBox(height: 12),
Text('Rs. ${amount.toStringAsFixed(0)}', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color)),
],
),
),
);
}
@override
Widget build(BuildContext context) {
// Compute totals
final totalIncome = widget.transactions.where((t) => t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null)).fold(0.0, (s, t) => s + t.amount);
final totalExpense = widget.transactions.where((t) => t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null)).fold(0.0, (s, t) => s + t.amount);
final totalInvestment = widget.transactions.where((t) => t.type == 'INVESTMENT').fold(0.0, (s, t) => s + t.amount);
double totalPayablesPeriod = 0.0;
double totalReceivablesPeriod = 0.0;
for (var t in widget.transactions) {
if (t.toWalletId != null) {
final w = widget.wallets.where((w) => w.id == t.toWalletId);
if (w.isNotEmpty) {
final nature = w.first.nature;
if (nature == 'PAYABLES' || nature == 'LOAN') {
totalPayablesPeriod += t.amount;
} else if (nature == 'RECEIVABLES' || nature == 'LENDING') {
totalReceivablesPeriod += t.amount;
}
}
}
}
Color barColor = NatureColors.getColor(_selectedNature);
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: widget.filters.map((f) {
final isSelected = widget.selectedFilter == f;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f),
selected: isSelected,
onSelected: (val) {
if (val) {
if (f == 'Custom') {
widget.onPickCustomDateRange();
} else {
widget.onFilterChanged(f);
}
}
},
),
);
}).toList(),
),
),
if (widget.selectedFilter == 'Custom')
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'${widget.startDate.day} ${widget.startDate.month} - ${widget.endDate.day} ${widget.endDate.month}',
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Analytics', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
decoration: BoxDecoration(
color: barColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: DropdownButton<String>(
value: _selectedNature,
underline: const SizedBox(),
icon: Icon(Icons.arrow_drop_down, color: barColor),
style: TextStyle(color: barColor, fontWeight: FontWeight.bold),
items: const [
DropdownMenuItem(value: 'EXPENSE', child: Text('Expense')),
DropdownMenuItem(value: 'INCOME', child: Text('Income')),
DropdownMenuItem(value: 'PAYABLES', child: Text('Payables')),
DropdownMenuItem(value: 'RECEIVABLES', child: Text('Receivables')),
DropdownMenuItem(value: 'INVESTMENTS', child: Text('Investments')),
],
onChanged: (val) {
if (val != null) {
setState(() => _selectedNature = val);
}
},
),
),
],
),
const SizedBox(height: 24),
if (widget.transactions.isEmpty)
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24), side: BorderSide(color: Colors.grey.shade200)),
child: const Padding(
padding: EdgeInsets.all(32.0),
child: Center(child: Text('No transactions in this period.', style: TextStyle(color: Colors.grey))),
),
)
else ...[
DayWiseSpendingChart(
transactions: widget.transactions,
wallets: widget.wallets,
selectedNature: _selectedNature,
startDate: widget.startDate,
endDate: widget.endDate,
),
const SizedBox(height: 16),
CategorySpendingChart(
transactions: widget.transactions,
categories: widget.categories,
selectedNature: _selectedNature,
),
],
const SizedBox(height: 32),
const Text('Summary Totals', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.5,
children: [
_buildTotalCard('Income', totalIncome, NatureColors.getColor('INCOME'), LucideIcons.arrowDown),
_buildTotalCard('Expense', totalExpense, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUp),
_buildTotalCard('Investment', totalInvestment, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp),
_buildTotalCard('Payables', totalPayablesPeriod, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle),
_buildTotalCard('Receivables', totalReceivablesPeriod, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft),
],
),
const SizedBox(height: 32),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,100 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart';
class Insight {
final String title;
final String message;
final String type; // 'WARNING', 'SUCCESS', 'INFO'
Insight(this.title, this.message, this.type);
}
final insightProvider = Provider<Insight?>((ref) {
final transState = ref.watch(transactionProvider);
final budgetsState = ref.watch(budgetProvider);
final categoriesState = ref.watch(categoryProvider);
if (!transState.hasValue || !budgetsState.hasValue || !categoriesState.hasValue) {
return null;
}
final transactions = transState.value!;
final budgets = budgetsState.value!;
final categories = categoriesState.value!;
final now = DateTime.now();
final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year).toList();
final totalIncome = currentMonthTxs.where((t) => t.type == 'INCOME').fold(0.0, (s, t) => s + t.amount);
final totalExpense = currentMonthTxs.where((t) => t.type == 'EXPENSE').fold(0.0, (s, t) => s + t.amount);
// Rule 1: Expenses > Income
if (totalExpense > totalIncome && totalIncome > 0) {
return Insight(
'Spending Alert',
'You have spent more than you earned this month! (Rs. ${totalExpense.toStringAsFixed(0)} vs Rs. ${totalIncome.toStringAsFixed(0)})',
'WARNING'
);
}
// Rule 2: Over Budget Categories
for (var budget in budgets) {
final spent = currentMonthTxs
.where((t) => t.type == 'EXPENSE' && t.categoryId == budget.categoryId)
.fold(0.0, (s, t) => s + t.amount);
if (spent > budget.monthlyLimit) {
final categoryName = categories.firstWhere((c) => c.id == budget.categoryId, orElse: () => Category(id: 0, name: 'Unknown')).name;
return Insight(
'Budget Exceeded',
'You have exceeded your $categoryName budget by Rs. ${(spent - budget.monthlyLimit).toStringAsFixed(0)}.',
'WARNING'
);
} else if (spent > budget.monthlyLimit * 0.9) {
final categoryName = categories.firstWhere((c) => c.id == budget.categoryId, orElse: () => Category(id: 0, name: 'Unknown')).name;
return Insight(
'Nearing Budget Limit',
'Careful! You have used ${(spent / budget.monthlyLimit * 100).toStringAsFixed(0)}% of your $categoryName budget.',
'WARNING'
);
}
}
// Rule 3: Highest spending category warning if > 40% of total
if (totalExpense > 0) {
final Map<int, double> spentByCategory = {};
for (var t in currentMonthTxs.where((t) => t.type == 'EXPENSE')) {
if (t.categoryId != null) {
spentByCategory[t.categoryId!] = (spentByCategory[t.categoryId!] ?? 0) + t.amount;
}
}
if (spentByCategory.isNotEmpty) {
final maxEntry = spentByCategory.entries.reduce((a, b) => a.value > b.value ? a : b);
if (maxEntry.value > totalExpense * 0.4) {
final categoryName = categories.firstWhere((c) => c.id == maxEntry.key, orElse: () => Category(id: 0, name: 'Unknown')).name;
return Insight(
'High Concentration',
'${(maxEntry.value / totalExpense * 100).toStringAsFixed(0)}% of your expenses this month went to $categoryName.',
'INFO'
);
}
}
}
// Default Positive Insight
if (totalIncome > 0 && totalExpense < totalIncome * 0.5) {
return Insight(
'Great Job!',
'You have saved over 50% of your income this month. Keep it up!',
'SUCCESS'
);
}
return Insight(
'On Track',
'Your finances are looking stable this month.',
'SUCCESS'
);
});

View File

@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../dashboard/presentation/dashboard_screen.dart';
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
final List<Map<String, dynamic>> _pages = [
{
'title': 'Welcome to Kifi',
'description': 'A beautiful, intelligent way to track your personal finances.',
'icon': LucideIcons.wallet,
},
{
'title': 'Double-Entry, Simplified',
'description': 'We track money moving from one place to another. Every expense comes from a Wallet (like Cash) and goes to a Category (like Food).',
'icon': LucideIcons.arrowRightLeft,
},
{
'title': 'Intelligent Automation',
'description': 'Set up recurring transactions and let Kifi handle your monthly subscriptions and salary deposits automatically.',
'icon': LucideIcons.bot,
},
{
'title': 'Powerful Insights',
'description': 'View day-by-day spending trends and strict monthly budgets to take control of your financial future.',
'icon': LucideIcons.barChart2,
},
];
void _completeOnboarding() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('has_seen_onboarding', true);
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const DashboardScreen()),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
Expanded(
child: PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() => _currentPage = index);
},
itemCount: _pages.length,
itemBuilder: (context, index) {
final page = _pages[index];
return Padding(
padding: const EdgeInsets.all(40.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
page['icon'],
size: 100,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 60),
Text(
page['title'],
style: Theme.of(context).textTheme.displayLarge?.copyWith(fontSize: 28),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Text(
page['description'],
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.5,
),
textAlign: TextAlign.center,
),
],
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(40.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: List.generate(
_pages.length,
(index) => Container(
margin: const EdgeInsets.only(right: 8),
height: 8,
width: _currentPage == index ? 24 : 8,
decoration: BoxDecoration(
color: _currentPage == index
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
),
),
),
ElevatedButton(
onPressed: () {
if (_currentPage == _pages.length - 1) {
_completeOnboarding();
} else {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
child: Text(_currentPage == _pages.length - 1 ? 'Get Started' : 'Next'),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,358 @@
class Category {
final int id;
final String name;
final String? iconName;
Category({required this.id, required this.name, this.iconName});
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
id: json['id'],
name: json['name'],
iconName: json['iconName'],
);
}
Map<String, dynamic> toJson() => {'name': name, 'iconName': iconName};
}
class TransactionItem {
final int? id;
final String name;
final double amount;
TransactionItem({this.id, required this.name, required this.amount});
factory TransactionItem.fromJson(Map<String, dynamic> json) {
return TransactionItem(
id: json['id'],
name: json['name'],
amount: (json['amount'] is int) ? (json['amount'] as int).toDouble() : json['amount'],
);
}
Map<String, dynamic> toJson() => {
'name': name,
'amount': amount,
};
}
class TransactionAttachment {
final int id;
final String fileName;
final String filePath;
final String contentType;
TransactionAttachment({required this.id, required this.fileName, required this.filePath, required this.contentType});
factory TransactionAttachment.fromJson(Map<String, dynamic> json) {
return TransactionAttachment(
id: json['id'],
fileName: json['fileName'],
filePath: json['filePath'],
contentType: json['contentType'],
);
}
}
class Transaction {
final int id;
final int? categoryId;
final int? fromWalletId;
final int? toWalletId;
final String? type; // 'INCOME' or 'EXPENSE' or 'INVESTMENT' (kept for backward compatibility during migration)
final double amount;
final DateTime date;
final String? description;
final String? notes;
final String? investmentStatus;
final double? maturityAmount;
final double? profitLoss;
final DateTime? closingDate;
final DateTime? dueDate;
final String? alertSchedule;
final String? alertTime;
final List<TransactionItem>? items;
final List<TransactionAttachment>? attachments;
Transaction({
required this.id,
this.categoryId,
this.fromWalletId,
this.toWalletId,
this.type,
required this.amount,
required this.date,
this.description,
this.notes,
this.investmentStatus,
this.maturityAmount,
this.profitLoss,
this.closingDate,
this.dueDate,
this.alertSchedule,
this.alertTime,
this.items,
this.attachments,
});
factory Transaction.fromJson(Map<String, dynamic> json) {
return Transaction(
id: json['id'],
categoryId: json['categoryId'],
fromWalletId: json['fromWalletId'],
toWalletId: json['toWalletId'],
type: json['type'],
amount: (json['amount'] is int) ? (json['amount'] as int).toDouble() : json['amount'],
date: DateTime.parse(json['date']),
description: json['description'],
notes: json['notes'],
investmentStatus: json['investmentStatus'],
maturityAmount: json['maturityAmount'] != null ? ((json['maturityAmount'] is int) ? (json['maturityAmount'] as int).toDouble() : json['maturityAmount']) : null,
profitLoss: json['profitLoss'] != null ? ((json['profitLoss'] is int) ? (json['profitLoss'] as int).toDouble() : json['profitLoss']) : null,
closingDate: json['closingDate'] != null ? DateTime.parse(json['closingDate']) : null,
dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : null,
alertSchedule: json['alertSchedule'],
alertTime: json['alertTime'],
items: json['items'] != null ? (json['items'] as List).map((i) => TransactionItem.fromJson(i)).toList() : null,
attachments: json['attachments'] != null ? (json['attachments'] as List).map((i) => TransactionAttachment.fromJson(i)).toList() : null,
);
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{
'categoryId': categoryId,
'fromWalletId': fromWalletId,
'toWalletId': toWalletId,
'type': type,
'amount': amount,
'date': date.toIso8601String().split('T')[0],
'description': description,
'notes': notes,
'investmentStatus': investmentStatus,
'maturityAmount': maturityAmount,
'profitLoss': profitLoss,
'closingDate': closingDate?.toIso8601String().split('T')[0],
'dueDate': dueDate?.toIso8601String().split('T')[0],
'alertSchedule': alertSchedule,
'alertTime': alertTime,
};
if (items != null) {
map['items'] = items!.map((i) => i.toJson()).toList();
}
return map;
}
}
class Budget {
final int id;
final int? categoryId;
final int? walletId;
final double monthlyLimit;
final bool isShared;
Budget({required this.id, this.categoryId, this.walletId, required this.monthlyLimit, this.isShared = false});
factory Budget.fromJson(Map<String, dynamic> json) {
double limit = 0.0;
if (json['monthlyLimit'] != null) {
if (json['monthlyLimit'] is num) {
limit = (json['monthlyLimit'] as num).toDouble();
} else if (json['monthlyLimit'] is String) {
limit = double.tryParse(json['monthlyLimit']) ?? 0.0;
}
}
int parseId(dynamic val, [int defaultVal = 0]) {
if (val == null) return defaultVal;
if (val is int) return val;
if (val is num) return val.toInt();
if (val is String) return int.tryParse(val) ?? double.tryParse(val)?.toInt() ?? defaultVal;
return defaultVal;
}
return Budget(
id: parseId(json['id']),
categoryId: json['categoryId'] != null ? parseId(json['categoryId']) : null,
walletId: json['walletId'] != null ? parseId(json['walletId']) : null,
monthlyLimit: limit,
isShared: json['isShared'] ?? false,
);
}
Map<String, dynamic> toJson() => {
'categoryId': categoryId,
'walletId': walletId,
'monthlyLimit': monthlyLimit,
'isShared': isShared,
};
}
class RecurringTransaction {
final int id;
final int? categoryId;
final int? fromWalletId;
final int? toWalletId;
final String type;
final double amount;
final String frequency; // DAILY, WEEKLY, MONTHLY
final DateTime? nextExecutionDate;
final DateTime? endDate;
final String? status;
final String? description;
RecurringTransaction({
required this.id,
this.categoryId,
this.fromWalletId,
this.toWalletId,
required this.type,
required this.amount,
required this.frequency,
this.nextExecutionDate,
this.endDate,
this.status,
this.description,
});
factory RecurringTransaction.fromJson(Map<String, dynamic> json) {
return RecurringTransaction(
id: json['id'],
categoryId: json['categoryId'],
fromWalletId: json['fromWalletId'],
toWalletId: json['toWalletId'],
type: json['type'],
amount: (json['amount'] is int) ? (json['amount'] as int).toDouble() : json['amount'],
frequency: json['frequency'],
nextExecutionDate: json['nextExecutionDate'] != null ? DateTime.parse(json['nextExecutionDate']) : null,
endDate: json['endDate'] != null ? DateTime.parse(json['endDate']) : null,
status: json['status'],
description: json['description'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'categoryId': categoryId,
'fromWalletId': fromWalletId,
'toWalletId': toWalletId,
'type': type,
'amount': amount,
'frequency': frequency,
'nextExecutionDate': nextExecutionDate?.toIso8601String(),
'endDate': endDate?.toIso8601String(),
'status': status ?? 'ACTIVE',
'description': description,
};
}
}
class Wallet {
final int id;
final String name;
final int ownerId;
final String? nature;
final double balance;
final String? currency;
final String? icon;
final String? color;
Wallet({
required this.id,
required this.name,
required this.ownerId,
this.nature,
this.balance = 0.0,
this.currency,
this.icon,
this.color,
});
factory Wallet.fromJson(Map<String, dynamic> json) {
int parseId(dynamic val, [int defaultVal = 0]) {
if (val == null) return defaultVal;
if (val is int) return val;
if (val is num) return val.toInt();
if (val is String) return int.tryParse(val) ?? double.tryParse(val)?.toInt() ?? defaultVal;
return defaultVal;
}
double parseDouble(dynamic val, [double defaultVal = 0.0]) {
if (val == null) return defaultVal;
if (val is num) return val.toDouble();
if (val is String) return double.tryParse(val) ?? defaultVal;
return defaultVal;
}
return Wallet(
id: parseId(json['id']),
name: json['name'],
ownerId: parseId(json['ownerId']),
nature: json['nature'],
balance: parseDouble(json['balance']),
currency: json['currency'],
icon: json['icon'],
color: json['color'],
);
}
Map<String, dynamic> toJson() => {
'name': name,
'nature': nature,
'currency': currency,
'initialBalance': balance, // Note: backend uses initialBalance on creation
'icon': icon,
'color': color,
};
}
class WalletInvitation {
final int id;
final int walletId;
final int inviterId;
final String inviteeEmail;
final String status;
final String createdAt;
WalletInvitation({
required this.id,
required this.walletId,
required this.inviterId,
required this.inviteeEmail,
required this.status,
required this.createdAt,
});
factory WalletInvitation.fromJson(Map<String, dynamic> json) {
return WalletInvitation(
id: json['id'],
walletId: json['walletId'],
inviterId: json['inviterId'],
inviteeEmail: json['inviteeEmail'],
status: json['status'],
createdAt: json['createdAt'] ?? '',
);
}
}
class WalletMember {
final int userId;
final String email;
final String role;
final String joinedAt;
WalletMember({
required this.userId,
required this.email,
required this.role,
required this.joinedAt,
});
factory WalletMember.fromJson(Map<String, dynamic> json) {
return WalletMember(
userId: json['userId'],
email: json['email'],
role: json['role'],
joinedAt: json['joinedAt'] ?? '',
);
}
}

View File

@@ -0,0 +1,220 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../../core/network/dio_client.dart';
import 'models.dart';
class ApiRepository {
final Dio dio = DioClient().dio;
Future<List<Category>> getCategories() async {
final response = await dio.get('/categories');
return (response.data as List).map((j) => Category.fromJson(j)).toList();
}
Future<Category> addCategory(Category category) async {
final response = await dio.post('/categories', data: category.toJson());
return Category.fromJson(response.data);
}
Future<List<Transaction>> getTransactions() async {
final response = await dio.get('/transactions');
return (response.data as List).map((j) => Transaction.fromJson(j)).toList();
}
Future<Map<String, dynamic>> searchTransactions({
String? type,
int? walletId,
int? categoryId,
String? startDate,
String? endDate,
String? search,
int page = 0,
int size = 20,
}) async {
final queryParams = {
if (type != null) 'type': type,
if (walletId != null) 'walletId': walletId,
if (categoryId != null) 'categoryId': categoryId,
if (startDate != null) 'startDate': startDate,
if (endDate != null) 'endDate': endDate,
if (search != null && search.isNotEmpty) 'search': search,
'page': page,
'size': size,
};
final response = await dio.get('/transactions/search', queryParameters: queryParams);
final content = (response.data['content'] as List).map((j) => Transaction.fromJson(j)).toList();
final totalElements = response.data['totalElements'] as int;
final totalPages = response.data['totalPages'] as int;
return {
'content': content,
'totalElements': totalElements,
'totalPages': totalPages,
};
}
Future<Transaction> addTransaction(Transaction transaction) async {
final response = await dio.post('/transactions', data: transaction.toJson());
return Transaction.fromJson(response.data);
}
Future<Transaction> updateTransaction(Transaction transaction) async {
final response = await dio.put('/transactions/${transaction.id}', data: transaction.toJson());
return Transaction.fromJson(response.data);
}
Future<void> deleteTransaction(int id) async {
await dio.delete('/transactions/$id');
}
Future<TransactionAttachment> addAttachment(int transactionId, String fileName, String contentType, String base64Content) async {
final bytes = base64Decode(base64Content);
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: fileName, contentType: DioMediaType.parse(contentType)),
});
final response = await dio.post('/transactions/$transactionId/attachments', data: formData);
return TransactionAttachment.fromJson(response.data);
}
Future<void> deleteAttachment(int attachmentId) async {
await dio.delete('/transactions/attachments/$attachmentId');
}
Future<Transaction> closeInvestment(int transactionId, double maturityAmount, DateTime closingDate, int toWalletId) async {
final response = await dio.put('/transactions/$transactionId/close-investment', data: {
'maturityAmount': maturityAmount,
'closingDate': closingDate.toIso8601String().split('T')[0],
'toWalletId': toWalletId,
});
return Transaction.fromJson(response.data);
}
// Budgets
Future<List<Budget>> getBudgets() async {
try {
final response = await dio.get('/budgets');
if (response.data is List) {
return (response.data as List).map((j) => Budget.fromJson(j)).toList();
} else if (response.data is Map && response.data.containsKey('data')) {
return (response.data['data'] as List).map((j) => Budget.fromJson(j)).toList();
}
return [];
} catch (e) {
print('Error fetching budgets: $e');
return [];
}
}
Future<Budget> addOrUpdateBudget(Budget budget) async {
final response = await dio.post('/budgets', data: budget.toJson());
return Budget.fromJson(response.data);
}
// Recurring Transactions
Future<List<RecurringTransaction>> getRecurringTransactions() async {
final response = await dio.get('/recurring-transactions');
return (response.data as List).map((j) => RecurringTransaction.fromJson(j)).toList();
}
Future<RecurringTransaction> addRecurringTransaction(RecurringTransaction rt) async {
final response = await dio.post('/recurring-transactions', data: rt.toJson());
return RecurringTransaction.fromJson(response.data);
}
Future<void> deleteRecurringTransaction(int id) async {
await dio.delete('/recurring-transactions/$id');
}
// Reports
Future<List<int>> exportTransactions() async {
final response = await dio.get(
'/reports/export',
options: Options(responseType: ResponseType.bytes),
);
return response.data;
}
// Wallets
Future<List<Wallet>> getWallets() async {
final response = await dio.get('/wallets');
return (response.data as List).map((j) => Wallet.fromJson(j)).toList();
}
Future<Wallet> createWallet({
required String name,
String nature = 'CASH',
double initialBalance = 0.0,
String currency = 'INR',
String? icon,
String? color,
}) async {
final response = await dio.post('/wallets', data: {
'name': name,
'nature': nature,
'initialBalance': initialBalance,
'currency': currency,
'icon': icon,
'color': color,
});
return Wallet.fromJson(response.data);
}
Future<Wallet> editWallet({
required int id,
String? name,
String? nature,
String? icon,
String? color,
}) async {
final response = await dio.put('/wallets/$id', data: {
if (name != null) 'name': name,
if (nature != null) 'nature': nature,
if (icon != null) 'icon': icon,
if (color != null) 'color': color,
});
return Wallet.fromJson(response.data);
}
Future<void> deleteWallet(int id) async {
try {
await dio.delete('/wallets/$id');
} on DioException catch (e) {
if (e.response != null && e.response!.data != null && e.response!.data is Map) {
throw Exception(e.response!.data['error'] ?? 'Failed to delete wallet');
}
throw Exception('Failed to delete wallet');
}
}
Future<void> inviteUserToWallet(int walletId, String email) async {
await dio.post('/wallets/$walletId/invite', data: {'email': email});
}
Future<List<WalletInvitation>> getInvitations() async {
final response = await dio.get('/wallets/invitations');
return (response.data as List).map((x) => WalletInvitation.fromJson(x)).toList();
}
Future<void> acceptInvitation(int invitationId) async {
await dio.post('/wallets/invitations/$invitationId/accept');
}
Future<void> rejectInvitation(int invitationId) async {
await dio.post('/wallets/invitations/$invitationId/reject');
}
Future<List<WalletMember>> getWalletMembers(int walletId) async {
final response = await dio.get('/wallets/$walletId/members');
return (response.data as List).map((x) => WalletMember.fromJson(x)).toList();
}
Future<void> removeWalletMember(int walletId, int memberId) async {
await dio.delete('/wallets/$walletId/members/$memberId');
}
Future<List<String>> getKnownContacts() async {
final response = await dio.get('/wallets/user-contacts');
return (response.data as List).map((x) => x.toString()).toList();
}
}

View File

@@ -0,0 +1,28 @@
class WalletInvitation {
final int id;
final int walletId;
final int inviterId;
final String inviteeEmail;
final String status;
final String createdAt;
WalletInvitation({
required this.id,
required this.walletId,
required this.inviterId,
required this.inviteeEmail,
required this.status,
required this.createdAt,
});
factory WalletInvitation.fromJson(Map<String, dynamic> json) {
return WalletInvitation(
id: json['id'],
walletId: json['walletId'],
inviterId: json['inviterId'],
inviteeEmail: json['inviteeEmail'],
status: json['status'],
createdAt: json['createdAt'] ?? '',
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../providers/providers.dart';
import '../../dashboard/presentation/maturity_dialog.dart';
import '../providers/paginated_transaction_provider.dart';
import '../../../core/widgets/shimmer_loading.dart';
import '../../../core/widgets/empty_state.dart';
import 'add_transaction_screen.dart';
import '../presentation/widgets/transaction_filter_sheet.dart';
import '../../../core/theme/nature_colors.dart';
class AllTransactionsScreen extends ConsumerStatefulWidget {
const AllTransactionsScreen({super.key});
@override
ConsumerState<AllTransactionsScreen> createState() => _AllTransactionsScreenState();
}
class _AllTransactionsScreenState extends ConsumerState<AllTransactionsScreen> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
ref.read(paginatedTransactionProvider.notifier).loadMore();
}
});
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String value) {
// Basic debounce for text search
Future.delayed(const Duration(milliseconds: 500), () {
if (_searchController.text == value) {
ref.read(paginatedTransactionProvider.notifier).updateFilters(search: value);
}
});
}
void _openFilterSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const TransactionFilterSheet(),
);
}
@override
Widget build(BuildContext context) {
final paginatedState = ref.watch(paginatedTransactionProvider);
final transactions = paginatedState.transactions;
final categoriesState = ref.watch(categoryProvider);
final walletsState = ref.watch(walletProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Transactions'),
actions: [
IconButton(
icon: const Icon(LucideIcons.filter),
onPressed: _openFilterSheet,
),
IconButton(
icon: const Icon(LucideIcons.plus),
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
},
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Search transactions...',
prefixIcon: const Icon(LucideIcons.search),
suffixIcon: _searchController.text.isNotEmpty ? IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
) : null,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
),
),
body: Builder(
builder: (context) {
if (transactions.isEmpty && paginatedState.isLoading) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 8,
itemBuilder: (context, index) => const ShimmerCard(),
);
}
if (transactions.isEmpty) {
return const EmptyStateWidget(
icon: LucideIcons.fileText,
title: 'No Transactions',
message: 'No transactions found matching your criteria.',
);
}
return RefreshIndicator(
onRefresh: () async {
ref.read(paginatedTransactionProvider.notifier).clearFilters();
_searchController.clear();
},
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
itemCount: transactions.length + (paginatedState.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == transactions.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
final t = transactions[index];
final isIncome = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null);
final isInvestment = t.type == 'INVESTMENT';
final isExpense = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null);
final isTransfer = t.type == 'TRANSFER' && t.fromWalletId != null && t.toWalletId != null;
Color typeColor = Colors.grey;
IconData typeIcon = LucideIcons.arrowRightLeft;
if (isIncome) {
typeColor = NatureColors.getColor('INCOME');
typeIcon = LucideIcons.arrowDownCircle;
} else if (isExpense) {
typeColor = NatureColors.getColor('EXPENSE');
typeIcon = LucideIcons.arrowUpCircle;
} else if (isInvestment) {
typeColor = NatureColors.getColor('INVESTMENTS');
typeIcon = LucideIcons.trendingUp;
} else if (isTransfer) {
typeColor = NatureColors.getColor('TRANSFER');
if (walletsState.hasValue) {
final toW = walletsState.value!.where((w) => w.id == t.toWalletId).firstOrNull;
final fromW = walletsState.value!.where((w) => w.id == t.fromWalletId).firstOrNull;
if (toW != null && (toW.nature == 'PAYABLES' || toW.nature == 'LOAN')) {
typeColor = NatureColors.getColor('PAYABLES');
typeIcon = LucideIcons.alertCircle;
} else if (toW != null && (toW.nature == 'RECEIVABLES' || toW.nature == 'LENDING')) {
typeColor = NatureColors.getColor('RECEIVABLES');
typeIcon = LucideIcons.arrowDownLeft;
} else if (fromW != null && (fromW.nature == 'PAYABLES' || fromW.nature == 'LOAN')) {
typeColor = NatureColors.getColor('PAYABLES');
typeIcon = LucideIcons.alertCircle;
} else if (fromW != null && (fromW.nature == 'RECEIVABLES' || fromW.nature == 'LENDING')) {
typeColor = NatureColors.getColor('RECEIVABLES');
typeIcon = LucideIcons.arrowDownLeft;
}
}
}
String categoryName = 'Unknown';
if (categoriesState.hasValue) {
final match = categoriesState.value!.where((c) => c.id == t.categoryId);
if (match.isNotEmpty) categoryName = match.first.name;
}
String accountName = 'Unknown';
if (walletsState.hasValue) {
if (isExpense && t.toWalletId != null) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) accountName = match.first.name;
} else if (isIncome && t.toWalletId != null) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) accountName = match.first.name;
} else if (isTransfer && t.toWalletId != null) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) accountName = 'To ${match.first.name}';
}
}
String fromName = '';
if (t.fromWalletId != null && walletsState.hasValue) {
final match = walletsState.value!.where((w) => w.id == t.fromWalletId);
if (match.isNotEmpty) fromName = match.first.name;
}
String toName = '';
if (t.toWalletId != null && walletsState.hasValue) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) toName = match.first.name;
}
String subtitleText = '';
if (categoryName == 'Unknown' && fromName.isNotEmpty && toName.isNotEmpty) {
subtitleText = '$fromName$toName${DateFormat('MMM dd, yyyy').format(t.date)}';
} else if (categoryName == 'Unknown' && toName.isNotEmpty) {
subtitleText = 'To $toName${DateFormat('MMM dd, yyyy').format(t.date)}';
} else if (categoryName == 'Unknown' && fromName.isNotEmpty) {
subtitleText = 'From $fromName${DateFormat('MMM dd, yyyy').format(t.date)}';
} else {
subtitleText = '$categoryName${DateFormat('MMM dd, yyyy').format(t.date)}${fromName.isNotEmpty ? ' • 💼 $fromName' : ''}';
}
return Dismissible(
key: Key(t.id.toString()),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(LucideIcons.trash2, color: Colors.white),
),
confirmDismiss: (dir) async {
return await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Transaction?'),
content: const Text('Are you sure you want to delete this transaction?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete', style: TextStyle(color: Colors.red))
),
],
),
);
},
onDismissed: (dir) {
ref.read(transactionProvider.notifier).deleteTransaction(t.id);
ref.read(paginatedTransactionProvider.notifier).removeTransactionFromState(t.id);
},
child: Card(
margin: const EdgeInsets.only(bottom: 12),
child: Column(
children: [
ListTile(
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => AddTransactionScreen(transaction: t)));
},
leading: CircleAvatar(
backgroundColor: typeColor.withValues(alpha: 0.1),
child: Icon(typeIcon, color: typeColor),
),
title: Text((t.description != null && t.description!.isNotEmpty) ? t.description! : accountName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(subtitleText),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isIncome ? '+' : (isTransfer ? '' : '-')}Rs. ${t.amount.toStringAsFixed(0)}',
style: TextStyle(fontWeight: FontWeight.bold, color: typeColor),
),
if (t.investmentStatus == 'OPEN' && (typeColor == NatureColors.getColor('INVESTMENTS') || typeColor == NatureColors.getColor('RECEIVABLES')))
GestureDetector(
onTap: () {
showDialog(context: context, builder: (_) => MaturityDialog(transaction: t));
},
child: const Padding(
padding: EdgeInsets.only(top: 4.0),
child: Text('Close', style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 12)),
),
),
],
),
),
],
),
),
);
},
),
);
}
),
);
}
}

View File

@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
class AttachmentGalleryScreen extends StatefulWidget {
final List<ImageProvider> images;
final int initialIndex;
const AttachmentGalleryScreen({
super.key,
required this.images,
required this.initialIndex,
});
@override
State<AttachmentGalleryScreen> createState() => _AttachmentGalleryScreenState();
}
class _AttachmentGalleryScreenState extends State<AttachmentGalleryScreen> {
late PageController _pageController;
int _currentIndex = 0;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
iconTheme: const IconThemeData(color: Colors.white),
title: Text(
'${_currentIndex + 1} / ${widget.images.length}',
style: const TextStyle(color: Colors.white),
),
),
body: PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentIndex = index;
});
},
itemCount: widget.images.length,
itemBuilder: (context, index) {
return _ZoomableImage(image: widget.images[index]);
},
),
);
}
}
class _ZoomableImage extends StatefulWidget {
final ImageProvider image;
const _ZoomableImage({required this.image});
@override
State<_ZoomableImage> createState() => _ZoomableImageState();
}
class _ZoomableImageState extends State<_ZoomableImage> with SingleTickerProviderStateMixin {
final TransformationController _transformationController = TransformationController();
late AnimationController _animationController;
Animation<Matrix4>? _animation;
TapDownDetails? _doubleTapDetails;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
)..addListener(() {
if (_animation != null) {
_transformationController.value = _animation!.value;
}
});
}
@override
void dispose() {
_animationController.dispose();
_transformationController.dispose();
super.dispose();
}
void _handleDoubleTap() {
if (_doubleTapDetails == null) return;
final position = _doubleTapDetails!.localPosition;
final matrix = _transformationController.value;
final scale = matrix.getMaxScaleOnAxis();
Matrix4 endMatrix;
if (scale > 1.0) {
// Zoom out
endMatrix = Matrix4.identity();
} else {
// Zoom in
endMatrix = Matrix4.identity()
..translate(-position.dx * 1.5, -position.dy * 1.5)
..scale(2.5);
}
_animation = Matrix4Tween(
begin: _transformationController.value,
end: endMatrix,
).animate(CurveTween(curve: Curves.easeInOut).animate(_animationController));
_animationController.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTapDown: (d) => _doubleTapDetails = d,
onDoubleTap: _handleDoubleTap,
child: InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 4.0,
child: Image(
image: widget.image,
fit: BoxFit.contain,
),
),
);
}
}

View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../providers/providers.dart';
import '../../providers/paginated_transaction_provider.dart';
class TransactionFilterSheet extends ConsumerStatefulWidget {
const TransactionFilterSheet({super.key});
@override
ConsumerState<TransactionFilterSheet> createState() => _TransactionFilterSheetState();
}
class _TransactionFilterSheetState extends ConsumerState<TransactionFilterSheet> {
String? _selectedType;
int? _selectedWalletId;
int? _selectedCategoryId;
@override
void initState() {
super.initState();
final currentState = ref.read(paginatedTransactionProvider);
_selectedType = currentState.type;
_selectedWalletId = currentState.walletId;
_selectedCategoryId = currentState.categoryId;
}
void _applyFilters() {
ref.read(paginatedTransactionProvider.notifier).updateFilters(
type: _selectedType,
walletId: _selectedWalletId,
categoryId: _selectedCategoryId,
);
Navigator.pop(context);
}
void _resetFilters() {
ref.read(paginatedTransactionProvider.notifier).clearFilters();
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final walletsState = ref.watch(walletProvider);
final categoriesState = ref.watch(categoryProvider);
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Filter Transactions', style: Theme.of(context).textTheme.titleLarge),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 24),
// Type Filter
Text('Transaction Type', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'ALL', label: Text('All')),
ButtonSegment(value: 'EXPENSE', label: Text('Expense')),
ButtonSegment(value: 'INCOME', label: Text('Income')),
],
selected: {_selectedType ?? 'ALL'},
onSelectionChanged: (set) {
setState(() => _selectedType = set.first == 'ALL' ? null : set.first);
},
),
const SizedBox(height: 24),
// Wallet Filter
Text('Wallet', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
DropdownButtonFormField<int>(
value: _selectedWalletId,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16),
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: [
const DropdownMenuItem(value: null, child: Text('All Wallets')),
if (walletsState.hasValue)
...walletsState.value!.map((w) => DropdownMenuItem(
value: w.id,
child: Text(w.name),
)),
],
onChanged: (val) => setState(() => _selectedWalletId = val),
),
const SizedBox(height: 24),
// Category Filter
Text('Category', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
DropdownButtonFormField<int>(
value: _selectedCategoryId,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16),
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: [
const DropdownMenuItem(value: null, child: Text('All Categories')),
if (categoriesState.hasValue)
...categoriesState.value!.map((c) => DropdownMenuItem(
value: c.id,
child: Text(c.name),
)),
],
onChanged: (val) => setState(() => _selectedCategoryId = val),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _resetFilters,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
foregroundColor: Colors.grey.shade700,
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: const Text('Reset', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: ElevatedButton(
onPressed: _applyFilters,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Apply Filters', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,115 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/repository.dart';
import '../data/models.dart';
import 'providers.dart';
class PaginatedTransactionState {
final List<Transaction> transactions;
final bool isLoading;
final bool hasMore;
final String? type;
final int? walletId;
final int? categoryId;
final String? search;
PaginatedTransactionState({
required this.transactions,
this.isLoading = false,
this.hasMore = true,
this.type,
this.walletId,
this.categoryId,
this.search,
});
PaginatedTransactionState copyWith({
List<Transaction>? transactions,
bool? isLoading,
bool? hasMore,
String? type,
int? walletId,
int? categoryId,
String? search,
}) {
return PaginatedTransactionState(
transactions: transactions ?? this.transactions,
isLoading: isLoading ?? this.isLoading,
hasMore: hasMore ?? this.hasMore,
type: type != null ? (type == 'ALL' ? null : type) : this.type,
walletId: walletId != null ? (walletId == -1 ? null : walletId) : this.walletId,
categoryId: categoryId != null ? (categoryId == -1 ? null : categoryId) : this.categoryId,
search: search != null ? (search == '' ? null : search) : this.search,
);
}
}
class PaginatedTransactionNotifier extends Notifier<PaginatedTransactionState> {
int _currentPage = 0;
@override
PaginatedTransactionState build() {
_currentPage = 0;
Future.microtask(() => loadMore());
return PaginatedTransactionState(transactions: []);
}
void updateFilters({String? type, int? walletId, int? categoryId, String? search}) {
state = state.copyWith(
type: type,
walletId: walletId,
categoryId: categoryId,
search: search,
transactions: [], // reset
hasMore: true,
);
_currentPage = 0;
loadMore();
}
void clearFilters() {
state = PaginatedTransactionState(transactions: []);
_currentPage = 0;
loadMore();
}
Future<void> loadMore() async {
if (state.isLoading || !state.hasMore) return;
state = state.copyWith(isLoading: true);
try {
final repository = ref.read(apiRepositoryProvider);
final response = await repository.searchTransactions(
page: _currentPage,
size: 20,
type: state.type,
walletId: state.walletId,
categoryId: state.categoryId,
search: state.search,
);
final newContent = response['content'] as List<Transaction>;
final totalPages = response['totalPages'] as int;
_currentPage++;
state = state.copyWith(
transactions: [...state.transactions, ...newContent],
isLoading: false,
hasMore: _currentPage < totalPages,
);
} catch (e) {
state = state.copyWith(isLoading: false);
print("Error loading paginated transactions: $e");
}
}
void removeTransactionFromState(int id) {
final updatedList = state.transactions.where((t) => t.id != id).toList();
state = state.copyWith(transactions: updatedList);
}
}
final paginatedTransactionProvider = NotifierProvider<PaginatedTransactionNotifier, PaginatedTransactionState>(() {
return PaginatedTransactionNotifier();
});

View File

@@ -0,0 +1,221 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/repository.dart';
import '../data/models.dart';
final apiRepositoryProvider = Provider((ref) => ApiRepository());
class CategoryNotifier extends AsyncNotifier<List<Category>> {
@override
FutureOr<List<Category>> build() {
return ref.watch(apiRepositoryProvider).getCategories();
}
Future<void> addCategory(String name, String? iconName) async {
final newCategory = await ref.read(apiRepositoryProvider).addCategory(Category(id: 0, name: name, iconName: iconName));
if (state.value != null) {
state = AsyncValue.data([...state.value!, newCategory]);
}
}
}
final categoryProvider = AsyncNotifierProvider<CategoryNotifier, List<Category>>(() => CategoryNotifier());
class TransactionNotifier extends AsyncNotifier<List<Transaction>> {
@override
FutureOr<List<Transaction>> build() {
return ref.watch(apiRepositoryProvider).getTransactions();
}
Future<void> addTransaction(Transaction transaction, {List<Map<String, String>>? base64Attachments}) async {
final newTransaction = await ref.read(apiRepositoryProvider).addTransaction(transaction);
// Upload attachments if any
if (base64Attachments != null && base64Attachments.isNotEmpty) {
for (var attachment in base64Attachments) {
await ref.read(apiRepositoryProvider).addAttachment(
newTransaction.id,
attachment['fileName']!,
attachment['contentType']!,
attachment['base64Content']!
);
}
}
if (state.value != null) {
// Re-fetch transactions to get the fully populated transaction with attachments from backend
ref.invalidateSelf();
}
}
Future<void> updateTransaction(Transaction transaction, {List<Map<String, String>>? base64Attachments, List<int>? deletedAttachmentIds}) async {
final updatedTransaction = await ref.read(apiRepositoryProvider).updateTransaction(transaction);
bool hasChanges = false;
if (base64Attachments != null && base64Attachments.isNotEmpty) {
for (var attachment in base64Attachments) {
await ref.read(apiRepositoryProvider).addAttachment(
updatedTransaction.id,
attachment['fileName']!,
attachment['contentType']!,
attachment['base64Content']!
);
}
hasChanges = true;
}
if (deletedAttachmentIds != null && deletedAttachmentIds.isNotEmpty) {
for (var id in deletedAttachmentIds) {
await ref.read(apiRepositoryProvider).deleteAttachment(id);
}
hasChanges = true;
}
if (hasChanges) {
ref.invalidateSelf();
} else {
if (state.value != null) {
final updatedList = state.value!.map((t) => t.id == updatedTransaction.id ? updatedTransaction : t).toList();
state = AsyncValue.data(updatedList);
}
}
}
Future<void> deleteTransaction(int id) async {
await ref.read(apiRepositoryProvider).deleteTransaction(id);
if (state.value != null) {
state = AsyncValue.data(state.value!.where((t) => t.id != id).toList());
}
}
Future<void> closeInvestment(int id, double maturityAmount, DateTime closingDate, int toWalletId) async {
final updatedTransaction = await ref.read(apiRepositoryProvider).closeInvestment(id, maturityAmount, closingDate, toWalletId);
if (state.value != null) {
final updatedList = state.value!.map((t) => t.id == updatedTransaction.id ? updatedTransaction : t).toList();
state = AsyncValue.data(updatedList);
}
}
}
final transactionProvider = AsyncNotifierProvider<TransactionNotifier, List<Transaction>>(() => TransactionNotifier());
class BudgetNotifier extends AsyncNotifier<List<Budget>> {
@override
FutureOr<List<Budget>> build() {
return ref.watch(apiRepositoryProvider).getBudgets();
}
Future<void> addOrUpdateBudget(Budget budget) async {
final updatedBudget = await ref.read(apiRepositoryProvider).addOrUpdateBudget(budget);
if (state.value != null) {
final list = List<Budget>.from(state.value!);
final index = list.indexWhere((b) =>
(b.categoryId != null && b.categoryId == budget.categoryId) ||
(b.walletId != null && b.walletId == budget.walletId)
);
if (index >= 0) {
list[index] = updatedBudget;
} else {
list.add(updatedBudget);
}
state = AsyncValue.data(list);
}
}
}
final budgetProvider = AsyncNotifierProvider<BudgetNotifier, List<Budget>>(() => BudgetNotifier());
class RecurringTransactionNotifier extends AsyncNotifier<List<RecurringTransaction>> {
@override
FutureOr<List<RecurringTransaction>> build() {
return ref.watch(apiRepositoryProvider).getRecurringTransactions();
}
Future<void> addRecurringTransaction(RecurringTransaction rt) async {
final newRt = await ref.read(apiRepositoryProvider).addRecurringTransaction(rt);
if (state.value != null) {
state = AsyncValue.data([newRt, ...state.value!]);
}
}
}
final recurringTransactionProvider = AsyncNotifierProvider<RecurringTransactionNotifier, List<RecurringTransaction>>(() => RecurringTransactionNotifier());
class WalletNotifier extends AsyncNotifier<List<Wallet>> {
@override
FutureOr<List<Wallet>> build() {
return ref.watch(apiRepositoryProvider).getWallets();
}
Future<Wallet> createWallet({
required String name,
String nature = 'CASH',
double initialBalance = 0.0,
String currency = 'INR',
String? icon,
String? color,
}) async {
final newWallet = await ref.read(apiRepositoryProvider).createWallet(
name: name,
nature: nature,
initialBalance: initialBalance,
currency: currency,
icon: icon,
color: color,
);
if (state.value != null) {
state = AsyncValue.data([...state.value!, newWallet]);
}
return newWallet;
}
Future<void> inviteUser(int walletId, String email) async {
await ref.read(apiRepositoryProvider).inviteUserToWallet(walletId, email);
}
Future<void> editWallet(int id, {String? name, String? nature, String? icon, String? color}) async {
final updated = await ref.read(apiRepositoryProvider).editWallet(
id: id,
name: name,
nature: nature,
icon: icon,
color: color,
);
if (state.value != null) {
state = AsyncValue.data(
state.value!.map((w) => w.id == id ? updated : w).toList(),
);
}
}
Future<void> deleteWallet(int id) async {
await ref.read(apiRepositoryProvider).deleteWallet(id);
if (state.value != null) {
state = AsyncValue.data(
state.value!.where((w) => w.id != id).toList(),
);
}
}
}
class InvitationNotifier extends AsyncNotifier<List<WalletInvitation>> {
@override
Future<List<WalletInvitation>> build() async {
return ref.read(apiRepositoryProvider).getInvitations();
}
Future<void> acceptInvitation(int invitationId) async {
await ref.read(apiRepositoryProvider).acceptInvitation(invitationId);
state = AsyncValue.data(state.value?.where((inv) => inv.id != invitationId).toList() ?? []);
ref.invalidate(walletProvider); // Refresh wallets
}
Future<void> rejectInvitation(int invitationId) async {
await ref.read(apiRepositoryProvider).rejectInvitation(invitationId);
state = AsyncValue.data(state.value?.where((inv) => inv.id != invitationId).toList() ?? []);
}
}
final walletProvider = AsyncNotifierProvider<WalletNotifier, List<Wallet>>(() => WalletNotifier());
final invitationProvider = AsyncNotifierProvider<InvitationNotifier, List<WalletInvitation>>(() => InvitationNotifier());

122
kifi-app/lib/main.dart Normal file
View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
import 'core/theme/app_theme.dart';
import 'core/theme/theme_provider.dart';
import 'features/auth/presentation/auth_screen.dart';
import 'features/dashboard/presentation/dashboard_screen.dart';
import 'features/onboarding/presentation/onboarding_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'core/network/dio_client.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(
const ProviderScope(
child: KifiApp(),
),
);
}
class KifiApp extends ConsumerStatefulWidget {
const KifiApp({super.key});
@override
ConsumerState<KifiApp> createState() => _KifiAppState();
}
class _KifiAppState extends ConsumerState<KifiApp> {
bool _isLoading = true;
bool _isAuthenticated = false;
bool _hasSeenOnboarding = false;
final LocalAuthentication _localAuth = LocalAuthentication();
String? _startupError;
@override
void initState() {
super.initState();
_checkAuthStatus();
}
Future<bool> _authenticateWithBiometrics() async {
try {
final canCheckBiometrics = await _localAuth.canCheckBiometrics;
final isDeviceSupported = await _localAuth.isDeviceSupported();
if (!canCheckBiometrics && !isDeviceSupported) {
return true; // Pass if device doesn't support biometrics to avoid locking users out
}
return await _localAuth.authenticate(
localizedReason: 'Please authenticate to access Kifi',
biometricOnly: true,
persistAcrossBackgrounding: true,
);
} catch (e) {
return false; // Fail safe
}
}
Future<void> _checkAuthStatus() async {
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'jwt_token');
final prefs = await SharedPreferences.getInstance();
final hasSeenOnboarding = prefs.getBool('has_seen_onboarding') ?? false;
if (token != null && token.isNotEmpty) {
// Prompt for biometrics if returning user
final authSuccess = await _authenticateWithBiometrics();
if (!authSuccess) {
setState(() {
_isAuthenticated = false;
_hasSeenOnboarding = hasSeenOnboarding;
_isLoading = false;
});
return; // User failed biometrics, leave them on login screen
}
setState(() {
_isAuthenticated = true;
_hasSeenOnboarding = hasSeenOnboarding;
_isLoading = false;
});
} else {
setState(() {
_isAuthenticated = false;
_hasSeenOnboarding = hasSeenOnboarding;
_isLoading = false;
});
}
} catch (e, stacktrace) {
setState(() {
_startupError = e.toString();
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final themeMode = ref.watch(themeProvider);
return MaterialApp(
navigatorKey: navigatorKey,
title: 'Kifi',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeMode,
home: _startupError != null
? Scaffold(body: Center(child: Padding(padding: const EdgeInsets.all(20), child: Text("Startup Error: $_startupError", style: const TextStyle(color: Colors.red)))))
: _isLoading
? const Scaffold(body: Center(child: CircularProgressIndicator()))
: (!_hasSeenOnboarding
? const OnboardingScreen()
: (_isAuthenticated ? const DashboardScreen() : const AuthScreen())),
);
}
}