V1 - Personal Account and Budgeting Done
Personal Account and Budgeting
This commit is contained in:
73
kifi-app/lib/core/network/dio_client.dart
Normal file
73
kifi-app/lib/core/network/dio_client.dart
Normal 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;
|
||||
}
|
||||
38
kifi-app/lib/core/security/crypto_service.dart
Normal file
38
kifi-app/lib/core/security/crypto_service.dart
Normal 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;
|
||||
}
|
||||
76
kifi-app/lib/core/services/notification_service.dart
Normal file
76
kifi-app/lib/core/services/notification_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
60
kifi-app/lib/core/services/ocr_service.dart
Normal file
60
kifi-app/lib/core/services/ocr_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
118
kifi-app/lib/core/theme/app_theme.dart
Normal file
118
kifi-app/lib/core/theme/app_theme.dart
Normal 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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
kifi-app/lib/core/theme/nature_colors.dart
Normal file
50
kifi-app/lib/core/theme/nature_colors.dart
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
41
kifi-app/lib/core/theme/theme_provider.dart
Normal file
41
kifi-app/lib/core/theme/theme_provider.dart
Normal 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();
|
||||
});
|
||||
59
kifi-app/lib/core/utils/snackbar_service.dart
Normal file
59
kifi-app/lib/core/utils/snackbar_service.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
kifi-app/lib/core/widgets/empty_state.dart
Normal file
48
kifi-app/lib/core/widgets/empty_state.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
64
kifi-app/lib/core/widgets/shimmer_loading.dart
Normal file
64
kifi-app/lib/core/widgets/shimmer_loading.dart
Normal 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),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user