682 lines
28 KiB
Dart
682 lines
28 KiB
Dart
import 'dart:developer';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:flutter_svg/svg.dart';
|
||
import 'package:kmobile/data/repositories/transaction_repository.dart';
|
||
import 'package:kmobile/di/injection.dart';
|
||
import 'package:kmobile/features/accounts/screens/account_info_screen.dart';
|
||
import 'package:kmobile/features/accounts/screens/account_statement_screen.dart';
|
||
import 'package:kmobile/features/accounts/screens/transaction_details_screen.dart';
|
||
import 'package:kmobile/features/auth/controllers/auth_cubit.dart';
|
||
import 'package:kmobile/features/auth/controllers/auth_state.dart';
|
||
import 'package:kmobile/features/customer_info/screens/customer_info_screen.dart';
|
||
import 'package:kmobile/features/beneficiaries/screens/manage_beneficiaries_screen.dart';
|
||
import 'package:kmobile/features/enquiry/screens/enquiry_screen.dart';
|
||
import 'package:kmobile/features/fund_transfer/screens/fund_transfer_screen.dart';
|
||
import 'package:kmobile/features/profile/profile_screen.dart';
|
||
import 'package:kmobile/features/quick_pay/screens/quick_pay_screen.dart';
|
||
import 'package:kmobile/security/secure_storage.dart';
|
||
import 'package:local_auth/local_auth.dart';
|
||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||
import 'package:google_fonts/google_fonts.dart';
|
||
import 'package:shimmer/shimmer.dart';
|
||
import 'package:kmobile/data/models/transaction.dart';
|
||
import '../../../l10n/app_localizations.dart';
|
||
|
||
class DashboardScreen extends StatefulWidget {
|
||
const DashboardScreen({super.key});
|
||
|
||
@override
|
||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||
}
|
||
|
||
class _DashboardScreenState extends State<DashboardScreen> {
|
||
int selectedAccountIndex = 0;
|
||
bool isVisible = false;
|
||
bool isRefreshing = false;
|
||
bool isBalanceLoading = false;
|
||
bool _biometricPromptShown = false;
|
||
bool _txLoading = false;
|
||
List<Transaction> _transactions = [];
|
||
bool _txInitialized = false;
|
||
|
||
Future<void> _loadTransactions(String accountNo) async {
|
||
setState(() {
|
||
_txLoading = true;
|
||
_transactions = [];
|
||
});
|
||
try {
|
||
final repo = getIt<TransactionRepository>();
|
||
final txs = await repo.fetchTransactions(accountNo);
|
||
var fiveTxns = <Transaction>[];
|
||
//only take the first 5 transactions
|
||
if (txs.length > 5) {
|
||
fiveTxns = txs.sublist(0, 5);
|
||
} else {
|
||
fiveTxns = txs;
|
||
}
|
||
setState(() => _transactions = fiveTxns);
|
||
} catch (e) {
|
||
log(accountNo, error: e);
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
AppLocalizations.of(context).failedToLoad(e.toString()),
|
||
),
|
||
),
|
||
);
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() => _txLoading = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _refreshAccountData(BuildContext context) async {
|
||
setState(() {
|
||
isRefreshing = true;
|
||
});
|
||
try {
|
||
// Call your AuthCubit or repository to refresh user/accounts data
|
||
await context.read<AuthCubit>().refreshUserData();
|
||
} catch (e) {
|
||
if (!context.mounted) {
|
||
return;
|
||
}
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(AppLocalizations.of(context).failedToRefresh),
|
||
),
|
||
);
|
||
}
|
||
setState(() {
|
||
isRefreshing = false;
|
||
});
|
||
}
|
||
|
||
Widget _buildBalanceShimmer() {
|
||
final theme = Theme.of(context);
|
||
return Shimmer.fromColors(
|
||
baseColor: theme.primaryColor,
|
||
highlightColor: theme.colorScheme.onPrimary,
|
||
child: Container(
|
||
width: 200, height: 42, color: theme.scaffoldBackgroundColor),
|
||
);
|
||
}
|
||
|
||
String getProcessedFirstName(String? name) {
|
||
if (name == null || name.trim().isEmpty) return '';
|
||
// Remove common titles
|
||
final titles = [
|
||
'mr.',
|
||
'mrs.',
|
||
'ms.',
|
||
'miss',
|
||
'dr.',
|
||
'shri',
|
||
'smt.',
|
||
'kumari',
|
||
];
|
||
String processed = name.trim().toLowerCase();
|
||
for (final title in titles) {
|
||
if (processed.startsWith(title)) {
|
||
processed = processed.replaceFirst(title, '').trim();
|
||
break;
|
||
}
|
||
}
|
||
// Take the first word (first name)
|
||
final firstName = processed.split(' ').first;
|
||
// Convert to title case
|
||
if (firstName.isEmpty) return '';
|
||
return firstName[0].toUpperCase() + firstName.substring(1);
|
||
}
|
||
|
||
String getFullAccountType(String? accountType) {
|
||
if (accountType == null || accountType.isEmpty) return 'N/A';
|
||
// Convert to title case
|
||
switch (accountType.toLowerCase()) {
|
||
case 'sa':
|
||
return AppLocalizations.of(context).savingsAccount;
|
||
case 'ln':
|
||
return AppLocalizations.of(context).loanAccount;
|
||
case 'td':
|
||
return AppLocalizations.of(context).termDeposit;
|
||
case 'rd':
|
||
return AppLocalizations.of(context).recurringDeposit;
|
||
default:
|
||
return AppLocalizations.of(context).unknownAccount;
|
||
}
|
||
}
|
||
|
||
Future<void> _showBiometricOptInDialog() async {
|
||
final storage = SecureStorage();
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (_) => AlertDialog(
|
||
title: Text(AppLocalizations.of(context).enableBiometric),
|
||
content: Text(AppLocalizations.of(context).useBiometricPrompt),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () async {
|
||
await storage.write('biometric_prompt_shown', 'true');
|
||
if (!mounted) return;
|
||
Navigator.of(context).pop();
|
||
},
|
||
child: Text(AppLocalizations.of(context).later),
|
||
),
|
||
TextButton(
|
||
onPressed: () async {
|
||
final auth = LocalAuthentication();
|
||
final canCheck = await auth.canCheckBiometrics;
|
||
if (!mounted) return;
|
||
bool ok = false;
|
||
if (canCheck) {
|
||
ok = await auth.authenticate(
|
||
localizedReason: AppLocalizations.of(context).scanBiometric,
|
||
);
|
||
}
|
||
if (ok) {
|
||
await storage.write('biometric_enabled', 'true');
|
||
}
|
||
await storage.write('biometric_prompt_shown', 'true');
|
||
if (!mounted) return;
|
||
Navigator.of(context).pop();
|
||
},
|
||
child: Text(AppLocalizations.of(context).enable),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return BlocListener<AuthCubit, AuthState>(
|
||
listener: (context, state) async {
|
||
if (state is Authenticated && !_biometricPromptShown) {
|
||
_biometricPromptShown = true;
|
||
final storage = getIt<SecureStorage>();
|
||
final already = await storage.read('biometric_prompt_shown');
|
||
if (already == null) {
|
||
_showBiometricOptInDialog();
|
||
}
|
||
}
|
||
},
|
||
child: Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
appBar: AppBar(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
automaticallyImplyLeading: false,
|
||
title: Text(
|
||
AppLocalizations.of(context).kconnect,
|
||
style: TextStyle(
|
||
color: theme.primaryColor,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
centerTitle: true,
|
||
actions: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 10.0),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(20),
|
||
onTap: () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => const ProfileScreen(),
|
||
),
|
||
);
|
||
},
|
||
child: CircleAvatar(
|
||
backgroundColor: Colors.grey[200],
|
||
radius: 20,
|
||
child: SvgPicture.asset(
|
||
'assets/images/avatar_male.svg',
|
||
width: 40,
|
||
height: 40,
|
||
fit: BoxFit.cover,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
body: BlocBuilder<AuthCubit, AuthState>(
|
||
builder: (context, state) {
|
||
if (state is AuthLoading || state is AuthInitial) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (state is Authenticated) {
|
||
final users = state.users;
|
||
final currAccount = users[selectedAccountIndex];
|
||
// first‐time load
|
||
if (!_txInitialized) {
|
||
_txInitialized = true;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_loadTransactions(currAccount.accountNo!);
|
||
});
|
||
}
|
||
final firstName = getProcessedFirstName(currAccount.name);
|
||
|
||
return SingleChildScrollView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: 8.0),
|
||
child: Text(
|
||
"${AppLocalizations.of(context).hi} $firstName!",
|
||
style: GoogleFonts.baumans().copyWith(
|
||
fontSize: 20,
|
||
color: theme.primaryColor,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Account Info Card
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 18,
|
||
vertical: 10,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: theme.primaryColor,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
"${getFullAccountType(currAccount.accountType)}: ",
|
||
style: TextStyle(
|
||
color: theme.colorScheme.onPrimary,
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
DropdownButton<int>(
|
||
value: selectedAccountIndex,
|
||
dropdownColor: theme.primaryColor,
|
||
underline: const SizedBox(),
|
||
icon: const Icon(Icons.keyboard_arrow_down),
|
||
iconEnabledColor: theme.colorScheme.onPrimary,
|
||
style: TextStyle(
|
||
color: theme.colorScheme.onPrimary,
|
||
fontSize: 18,
|
||
),
|
||
items: List.generate(users.length, (index) {
|
||
return DropdownMenuItem<int>(
|
||
value: index,
|
||
child: Text(
|
||
users[index].accountNo ?? 'N/A',
|
||
style: TextStyle(
|
||
color: theme.colorScheme.onPrimary,
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
onChanged: (int? newIndex) async {
|
||
if (newIndex == null ||
|
||
newIndex == selectedAccountIndex) {
|
||
return;
|
||
}
|
||
if (isBalanceLoading) return;
|
||
if (isVisible) {
|
||
setState(() {
|
||
isBalanceLoading = true;
|
||
selectedAccountIndex = newIndex;
|
||
});
|
||
await Future.delayed(
|
||
const Duration(milliseconds: 200),
|
||
);
|
||
setState(() {
|
||
isBalanceLoading = false;
|
||
});
|
||
} else {
|
||
setState(() {
|
||
selectedAccountIndex = newIndex;
|
||
});
|
||
}
|
||
await _loadTransactions(
|
||
users[newIndex].accountNo!,
|
||
);
|
||
},
|
||
),
|
||
const Spacer(),
|
||
IconButton(
|
||
icon: isRefreshing
|
||
? SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(
|
||
color: theme.colorScheme.onPrimary,
|
||
strokeWidth: 2,
|
||
),
|
||
)
|
||
: Icon(
|
||
Symbols.refresh,
|
||
color: theme.colorScheme.onPrimary,
|
||
),
|
||
onPressed: isRefreshing
|
||
? null
|
||
: () => _refreshAccountData(context),
|
||
tooltip: 'Refresh',
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 15),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
"₹ ",
|
||
style: TextStyle(
|
||
color: theme.colorScheme.onPrimary,
|
||
fontSize: 40,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
isRefreshing || isBalanceLoading
|
||
? _buildBalanceShimmer()
|
||
: Text(
|
||
isVisible
|
||
? currAccount.currentBalance ??
|
||
'0.00'
|
||
: '*****',
|
||
style: TextStyle(
|
||
color: theme.colorScheme.onPrimary,
|
||
fontSize: 40,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
InkWell(
|
||
onTap: () async {
|
||
if (isBalanceLoading) return;
|
||
if (!isVisible) {
|
||
setState(() {
|
||
isBalanceLoading = true;
|
||
});
|
||
await Future.delayed(
|
||
const Duration(seconds: 1),
|
||
);
|
||
setState(() {
|
||
isVisible = true;
|
||
isBalanceLoading = false;
|
||
});
|
||
} else {
|
||
setState(() {
|
||
isVisible = false;
|
||
});
|
||
}
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(8.0),
|
||
child: Icon(
|
||
isVisible
|
||
? Symbols.visibility_lock
|
||
: Symbols.visibility,
|
||
color: theme.scaffoldBackgroundColor,
|
||
weight: 800,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 15),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 18),
|
||
Text(
|
||
AppLocalizations.of(context).quickLinks,
|
||
style: const TextStyle(fontSize: 17),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Quick Links
|
||
GridView.count(
|
||
crossAxisCount: 4,
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
children: [
|
||
_buildQuickLink(
|
||
Symbols.id_card,
|
||
AppLocalizations.of(context).customerInfo,
|
||
() {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => CustomerInfoScreen(
|
||
user: users[selectedAccountIndex],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
_buildQuickLink(
|
||
Symbols.currency_rupee,
|
||
AppLocalizations.of(context).quickPay,
|
||
() {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => QuickPayScreen(
|
||
debitAccount: currAccount.accountNo!,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
_buildQuickLink(Symbols.send_money,
|
||
AppLocalizations.of(context).fundTransfer, () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => FundTransferScreen(
|
||
creditAccountNo:
|
||
users[selectedAccountIndex]
|
||
.accountNo!,
|
||
remitterName:
|
||
users[selectedAccountIndex]
|
||
.name!)));
|
||
}, disable: false),
|
||
_buildQuickLink(
|
||
Symbols.server_person,
|
||
AppLocalizations.of(context).accountInfo,
|
||
() {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => AccountInfoScreen(
|
||
users: users,
|
||
selectedIndex: selectedAccountIndex,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
_buildQuickLink(Symbols.receipt_long,
|
||
AppLocalizations.of(context).accountStatement,
|
||
() {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) =>
|
||
AccountStatementScreen(
|
||
accountNo: users[selectedAccountIndex]
|
||
.accountNo!,
|
||
balance: users[selectedAccountIndex]
|
||
.availableBalance!,
|
||
)));
|
||
}),
|
||
_buildQuickLink(Symbols.checkbook,
|
||
AppLocalizations.of(context).handleCheque, () {},
|
||
disable: true),
|
||
_buildQuickLink(Icons.group,
|
||
AppLocalizations.of(context).manageBeneficiary,
|
||
() {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) =>
|
||
ManageBeneficiariesScreen(
|
||
customerName: currAccount.name!)));
|
||
}, disable: false),
|
||
_buildQuickLink(Symbols.support_agent,
|
||
AppLocalizations.of(context).contactUs, () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) =>
|
||
const EnquiryScreen()));
|
||
}),
|
||
],
|
||
),
|
||
const SizedBox(height: 5),
|
||
|
||
// Recent Transactions
|
||
Text(
|
||
AppLocalizations.of(context).recentTransactions,
|
||
style: const TextStyle(fontSize: 17),
|
||
),
|
||
const SizedBox(height: 16),
|
||
if (_txLoading)
|
||
..._buildTransactionShimmer()
|
||
else if (_transactions.isNotEmpty)
|
||
..._transactions.map(
|
||
(tx) => ListTile(
|
||
leading: Icon(
|
||
tx.type == 'CR'
|
||
? Symbols.call_received
|
||
: Symbols.call_made,
|
||
color: tx.type == 'CR'
|
||
? const Color(0xFF10BB10)
|
||
: theme.colorScheme.error,
|
||
),
|
||
title: Text(
|
||
tx.date ?? '',
|
||
style: const TextStyle(fontSize: 15),
|
||
),
|
||
subtitle: Text(
|
||
tx.name != null
|
||
? (tx.name!.length > 18
|
||
? tx.name!.substring(0, 22)
|
||
: tx.name!)
|
||
: '',
|
||
style: const TextStyle(fontSize: 12),
|
||
),
|
||
trailing: Text(
|
||
"₹${tx.amount}",
|
||
style: const TextStyle(fontSize: 17),
|
||
),
|
||
onTap: () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) =>
|
||
TransactionDetailsScreen(transaction: tx),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
)
|
||
else
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||
child: Center(
|
||
child: Text(
|
||
AppLocalizations.of(context).noTransactions,
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
color: Colors.grey[600],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return Center(
|
||
child: Text(AppLocalizations.of(context).somethingWentWrong),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Widget> _buildTransactionShimmer() {
|
||
final theme = Theme.of(context);
|
||
return List.generate(3, (i) {
|
||
return ListTile(
|
||
leading: Shimmer.fromColors(
|
||
baseColor: Colors.grey[300]!,
|
||
highlightColor: Colors.grey[100]!,
|
||
child: CircleAvatar(
|
||
radius: 12, backgroundColor: theme.scaffoldBackgroundColor),
|
||
),
|
||
title: Shimmer.fromColors(
|
||
baseColor: Colors.grey[300]!,
|
||
highlightColor: Colors.grey[100]!,
|
||
child: Container(
|
||
height: 10, width: 100, color: theme.scaffoldBackgroundColor),
|
||
),
|
||
subtitle: Shimmer.fromColors(
|
||
baseColor: Colors.grey[300]!,
|
||
highlightColor: Colors.grey[100]!,
|
||
child: Container(
|
||
height: 8, width: 60, color: theme.scaffoldBackgroundColor),
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
Widget _buildQuickLink(
|
||
IconData icon,
|
||
String label,
|
||
VoidCallback onTap, {
|
||
bool disable = false,
|
||
}) {
|
||
final theme = Theme.of(context);
|
||
return InkWell(
|
||
onTap: disable ? null : onTap,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
icon,
|
||
size: 30,
|
||
color: disable
|
||
? theme.colorScheme.surfaceContainerHighest
|
||
: theme.primaryColor,
|
||
grade: 200,
|
||
weight: 700,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
label,
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|