Files
kmobile/lib/features/dashboard/screens/dashboard_screen.dart
2025-11-24 15:50:01 +05:30

716 lines
27 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/models/user.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/cheque/screens/cheque_management_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/features/service/screens/branch_locator_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>
with SingleTickerProviderStateMixin {
int selectedAccountIndex = 0;
Map<String, bool> _visibilityMap = {};
bool isRefreshing = false;
bool isBalanceLoading = false;
bool _biometricPromptShown = false;
bool _txLoading = false;
List<Transaction> _transactions = [];
bool _txInitialized = false;
PageController? _pageController;
@override
void dispose() {
_pageController?.dispose();
super.dispose();
}
Widget _buildAccountCard(User user, bool isSelected) {
final theme = Theme.of(context);
final bool isCardVisible = _visibilityMap[user.accountNo] ?? false;
// Animated scale for the selected card
final scale = isSelected ? 1.0 : 0.9;
return AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: scale,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFF01A04C),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
getFullAccountType(user.accountType),
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
Text(
user.accountNo ?? 'N/A',
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontSize: 14,
fontWeight: FontWeight.w700, // Changed to bold
),
overflow: TextOverflow.ellipsis,
),
],
),
),
if (isSelected) // Only show refresh for selected card
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 Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
"",
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontSize: 32, // Adjusted for space
fontWeight: FontWeight.w700,
),
),
if (isRefreshing || (isBalanceLoading && isSelected))
Expanded(child: _buildBalanceShimmer())
else
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
isCardVisible ? user.currentBalance ?? '0.00' : '*****',
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontSize: 32,
fontWeight: FontWeight.w700,
),
),
),
),
const Spacer(),
if (isSelected) // Only show toggle for selected card
InkWell(
onTap: () async {
if (isBalanceLoading) return;
final accountNo = user.accountNo;
if (accountNo == null) return;
final bool currentVisibility =
_visibilityMap[accountNo] ?? false;
if (!currentVisibility) {
// If it's currently hidden, we show it after a delay
setState(() {
isBalanceLoading = true;
});
await Future.delayed(
const Duration(milliseconds: 500)); // Shorter delay
setState(() {
_visibilityMap[accountNo] = true;
isBalanceLoading = false;
});
} else {
// If it's currently visible, we hide it immediately
setState(() {
_visibilityMap[accountNo] = false;
});
}
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
isCardVisible
? Symbols.visibility_lock
: Symbols.visibility,
color: theme.scaffoldBackgroundColor,
weight: 800,
),
),
),
],
),
const Spacer(),
],
),
),
);
}
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.colorScheme.primary,
highlightColor: theme.colorScheme.onPrimary,
child: Container(
height: 36, 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 'sb':
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;
case 'ca':
return "Current Account";
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,
leading: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () {
final authState = context.read<AuthCubit>().state;
String mobileNumberToPass = '';
String customerNo ='';
String customerName = '';
if (authState is Authenticated) {
if (selectedAccountIndex >= 0 &&
selectedAccountIndex < authState.users.length) {
mobileNumberToPass =
authState.users[selectedAccountIndex].mobileNo ?? '';
customerNo =
authState.users[selectedAccountIndex].cifNumber ?? '';
customerName=
authState.users[selectedAccountIndex].name ?? '';
}
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
ProfileScreen(mobileNumber: mobileNumberToPass, customerNo: customerNo, customerName: customerName),
),
);
},
child: CircleAvatar(
backgroundColor: Colors.grey[200],
radius: 20,
child: SvgPicture.asset(
'assets/images/avatar_male.svg',
width: 40,
height: 40,
fit: BoxFit.cover,
),
),
),
),
title: Text(
AppLocalizations.of(context).kccbMobile,
textAlign: TextAlign.left,
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
centerTitle: true,
),
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];
final accountType = currAccount.accountType?.toLowerCase();
final isPaymentDisabled = accountType != 'sa' &&
accountType != 'sb' &&
accountType != 'ca';
// firsttime load
if (!_txInitialized) {
_txInitialized = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadTransactions(currAccount.accountNo!);
});
}
_pageController ??= PageController(
initialPage: selectedAccountIndex,
viewportFraction: 0.85,
);
final firstName = getProcessedFirstName(currAccount.name);
return SingleChildScrollView(
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.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 16),
// Account Info Cards
SizedBox(
height: 160, // Adjusted height
child: PageView.builder(
controller: _pageController,
itemCount: users.length,
onPageChanged: (int newIndex) async {
if (newIndex == selectedAccountIndex) return;
// Hide the balance of the old card when scrolling away
final oldAccountNo = users[selectedAccountIndex].accountNo;
if (oldAccountNo != null) {
_visibilityMap[oldAccountNo] = false;
}
setState(() {
selectedAccountIndex = newIndex;
});
await _loadTransactions(
users[newIndex].accountNo!,
);
},
itemBuilder: (context, index) {
final user = users[index];
final isSelected = index == selectedAccountIndex;
return _buildAccountCard(user, isSelected);
},
),
),
const SizedBox(height: 18),
Text(
AppLocalizations.of(context).quickLinks,
style: const TextStyle(fontSize: 17),
),
const SizedBox(height: 24),
// Quick Links
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1,
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!,
),
),
);
},
disable: isPaymentDisabled,
),
_buildQuickLink(Symbols.send_money,
AppLocalizations.of(context).fundTransfer, () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => FundTransferScreen(
creditAccountNo:
users[selectedAccountIndex]
.accountNo!,
remitterName:
users[selectedAccountIndex].name!,
// Pass the full list of accounts
accounts: users)));
}, disable: isPaymentDisabled),
_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!,
accountType:
users[selectedAccountIndex]
.accountType!,
)));
}),
_buildQuickLink(Icons.location_pin, "Branch Locator",
() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const BranchLocatorScreen()));
}, disable: false),
_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()));
}),
_buildQuickLink(
Symbols.request_quote,
AppLocalizations.of(context).chequeManagement,
() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const ChequeManagementScreen(),
),
);
},
),
],
),
],
),
),
);
}
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 Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: InkWell(
onTap: disable ? null : onTap,
borderRadius: BorderRadius.circular(12.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 30,
color: disable
? theme.disabledColor
: theme.colorScheme.primary,
),
const SizedBox(height: 4),
Text(
label,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 12,
color: disable
? theme.disabledColor
: theme.colorScheme.onSurface,
),
),
],
),
),
);
}
}