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