Files
kmobile/lib/features/dashboard/screens/dashboard_screen.dart
2025-07-18 12:48:52 +05:30

576 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/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_beneficiary_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 'package:flutter_gen/gen_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);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content:
Text(AppLocalizations.of(context).failedToLoad(e.toString()))));
} finally {
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) {
ScaffoldMessenger.of(context).showSnackBar(
/*const*/ SnackBar(
content: Text(AppLocalizations.of(context).failedToRefresh)),
);
}
setState(() {
isRefreshing = false;
});
}
Widget _buildBalanceShimmer() {
return Shimmer.fromColors(
baseColor: Colors.white.withOpacity(0.7),
highlightColor: Colors.white.withOpacity(0.3),
child: Container(
width: 100,
height: 32,
color: Colors.white,
),
);
}
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');
Navigator.of(context).pop();
},
child: Text(AppLocalizations.of(context).later),
),
TextButton(
onPressed: () async {
final auth = LocalAuthentication();
final canCheck = await auth.canCheckBiometrics;
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');
Navigator.of(context).pop();
},
child: Text(AppLocalizations.of(context).enable),
),
],
),
);
}
@override
Widget build(BuildContext 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: const Color(0xfff5f9fc),
appBar: AppBar(
backgroundColor: const Color(0xfff5f9fc),
automaticallyImplyLeading: false,
title: Text(
AppLocalizations.of(context).kMobile,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500),
),
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];
// firsttime 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.montserrat().copyWith(
fontSize: 25,
color: Theme.of(context).primaryColorDark,
fontWeight: FontWeight.w700),
),
),
const SizedBox(height: 16),
// Account Info Card
Container(
padding: const EdgeInsets.symmetric(
horizontal: 18, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(AppLocalizations.of(context).accountNumber,
style: TextStyle(
color: Colors.white, fontSize: 12)),
DropdownButton<int>(
value: selectedAccountIndex,
dropdownColor: Theme.of(context).primaryColor,
underline: const SizedBox(),
icon: const Icon(Icons.keyboard_arrow_down),
iconEnabledColor: Colors.white,
style: const TextStyle(
color: Colors.white, fontSize: 14),
items: List.generate(users.length, (index) {
return DropdownMenuItem<int>(
value: index,
child: Text(
users[index].accountNo ?? 'N/A',
style: const TextStyle(
color: Colors.white, fontSize: 14),
),
);
}),
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
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Icon(Icons.refresh,
color: Colors.white),
onPressed: isRefreshing
? null
: () => _refreshAccountData(context),
tooltip: 'Refresh',
),
],
),
Text(
getFullAccountType(currAccount.accountType),
style: const TextStyle(
color: Colors.white, fontSize: 16),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Text("",
style: TextStyle(
color: Colors.white,
fontSize: 40,
fontWeight: FontWeight.w700)),
isRefreshing || isBalanceLoading
? _buildBalanceShimmer()
: Text(
isVisible
? currAccount.currentBalance ?? '0.00'
: '********',
style: const TextStyle(
color: Colors.white,
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: Icon(
isVisible
? Symbols.visibility_lock
: Symbols.visibility,
color: Colors.white),
),
],
),
const SizedBox(height: 15),
],
),
),
const SizedBox(height: 18),
Text(
AppLocalizations.of(context).quickLinks,
style: 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) =>
const FundTransferBeneficiaryScreen()));
}, disable: true),
_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!,
)));
}),
_buildQuickLink(Symbols.checkbook,
AppLocalizations.of(context).handleCheque, () {},
disable: true),
_buildQuickLink(Icons.group,
AppLocalizations.of(context).manageBeneficiary, () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const ManageBeneficiariesScreen()));
}, disable: true),
_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: 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' ? Colors.green : Colors.red,
),
title: Text(
tx.name != null
? (tx.name!.length > 18
? tx.name!.substring(0, 20)
: tx.name!)
: '',
style: const TextStyle(fontSize: 14),
),
subtitle: Text(tx.date ?? '',
style: const TextStyle(fontSize: 12)),
trailing: Text("${tx.amount}",
style: const TextStyle(fontSize: 16)),
))
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() {
return List.generate(3, (i) {
return ListTile(
leading: Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: const CircleAvatar(radius: 12, backgroundColor: Colors.white),
),
title: Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Container(height: 10, width: 100, color: Colors.white),
),
subtitle: Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Container(height: 8, width: 60, color: Colors.white),
),
);
});
}
Widget _buildQuickLink(IconData icon, String label, VoidCallback onTap,
{bool disable = false}) {
return InkWell(
onTap: disable ? null : onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon,
size: 30,
color: disable ? Colors.grey : Theme.of(context).primaryColor),
const SizedBox(height: 4),
Text(label,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13)),
],
),
);
}
}