This commit is contained in:
2025-11-09 15:42:50 +05:30
parent 5b7f3f0096
commit 3e88aad43f
11 changed files with 332 additions and 98 deletions

BIN
flutter_01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -141,4 +141,25 @@ class AuthService {
}
return;
}
}
Future setTncflag() async{
try {
final response = await _dio.post(
'/api/auth/tnc',
data: {"flag": true},
);
if (response.statusCode != 200) {
throw AuthException('Failed to proceed with T&C');
}
}
on DioException catch (e) {
if (kDebugMode) {
print(e.toString());
}
throw NetworkException('Network error during T&C Setup');
} catch (e) {
throw UnexpectedException(
'Unexpected error: ${e.toString()}');
}
}
}

View File

@@ -10,6 +10,7 @@ import '../features/dashboard/screens/dashboard_screen.dart';
// import '../features/transactions/screens/transactions_screen.dart';
// import '../features/payments/screens/payments_screen.dart';
// import '../features/settings/screens/settings_screen.dart';
import 'package:kmobile/features/auth/screens/tnc_required_screen.dart';
class AppRoutes {
// Private constructor to prevent instantiation
@@ -34,7 +35,8 @@ class AppRoutes {
return MaterialPageRoute(builder: (_) => const SplashScreen());
case login:
return MaterialPageRoute(builder: (_) => const LoginScreen());
case TncRequiredScreen.routeName: // Renamed class
return MaterialPageRoute(builder: (_) => const TncRequiredScreen()); // Renamed class
case mPin:
return MaterialPageRoute(
builder: (_) => const MPinScreen(

View File

@@ -13,10 +13,11 @@ class AuthRepository {
static const _accessTokenKey = 'access_token';
static const _tokenExpiryKey = 'token_expiry';
static const _tncKey = 'tnc';
AuthRepository(this._authService, this._userService, this._secureStorage);
Future<List<User>> login(String customerNo, String password) async {
Future<(List<User>, AuthToken)> login(String customerNo, String password) async {
// Create credentials and call service
final credentials =
AuthCredentials(customerNo: customerNo, password: password);
@@ -27,7 +28,7 @@ class AuthRepository {
// Get and save user profile
final users = await _userService.getUserDetails();
return users;
return (users, authToken);
}
Future<bool> isLoggedIn() async {
@@ -47,6 +48,7 @@ class AuthRepository {
await _secureStorage.write(_accessTokenKey, token.accessToken);
await _secureStorage.write(
_tokenExpiryKey, token.expiresAt.toIso8601String());
await _secureStorage.write(_tncKey, token.tnc.toString());
}
Future<void> clearAuthTokens() async {
@@ -56,13 +58,27 @@ class AuthRepository {
Future<AuthToken?> _getAuthToken() async {
final accessToken = await _secureStorage.read(_accessTokenKey);
final expiryString = await _secureStorage.read(_tokenExpiryKey);
final tncString = await _secureStorage.read(_tncKey);
if (accessToken != null && expiryString != null) {
return AuthToken(
final authToken = AuthToken(
accessToken: accessToken,
expiresAt: DateTime.parse(expiryString),
tnc: tncString == 'true', // Parse 'true' string to true, otherwise false
);
return authToken;
}
return null;
}
Future<void> acceptTnc() async {
// This method calls the setTncFlag function
try {
await _authService.setTncflag();
} catch (e) {
// Handle or rethrow the error as needed
print('Error setting TNC flag: $e');
rethrow;
}
}
}

View File

@@ -62,7 +62,7 @@ Future<void> setupDependencies() async {
// Register controllers/cubits
getIt.registerFactory<AuthCubit>(
() => AuthCubit(getIt<AuthRepository>(), getIt<UserService>()));
() => AuthCubit(getIt<AuthRepository>(), getIt<UserService>(), getIt<SecureStorage>()));
}
Dio _createDioClient() {

View File

@@ -1,14 +1,20 @@
import 'package:bloc/bloc.dart';
import 'package:flutter/material.dart';
import 'package:kmobile/api/services/user_service.dart';
import 'package:kmobile/core/errors/exceptions.dart';
import 'package:kmobile/data/models/user.dart';
import 'package:kmobile/features/auth/models/auth_token.dart';
import 'package:kmobile/security/secure_storage.dart';
import '../../../data/repositories/auth_repository.dart';
import 'auth_state.dart';
class AuthCubit extends Cubit<AuthState> {
final AuthRepository _authRepository;
final UserService _userService;
final SecureStorage _secureStorage;
AuthCubit(this._authRepository, this._userService) : super(AuthInitial()) {
AuthCubit(this._authRepository, this._userService, this._secureStorage)
: super(AuthInitial()) {
checkAuthStatus();
}
@@ -29,22 +35,56 @@ class AuthCubit extends Cubit<AuthState> {
Future<void> refreshUserData() async {
try {
// emit(AuthLoading());
final users = await _userService.getUserDetails();
emit(Authenticated(users));
} catch (e) {
emit(AuthError('Failed to refresh user data: ${e.toString()}'));
// Optionally, re-emit the previous state or handle as needed
}
}
Future<void> login(String customerNo, String password) async {
emit(AuthLoading());
try {
final users = await _authRepository.login(customerNo, password);
emit(Authenticated(users));
final (users, authToken) = await _authRepository.login(customerNo, password);
if (authToken.tnc == false) {
// TNC not accepted, tell UI to show the dialog
emit(ShowTncDialog(authToken, users));
} else {
// TNC already accepted, emit Authenticated and then proceed to MPIN check
emit(Authenticated(users));
await _checkMpinAndNavigate();
}
} catch (e) {
emit(AuthError(e is AuthException ? e.message : e.toString()));
}
}
Future<void> onTncDialogResult(
bool agreed, AuthToken authToken, List<User> users) async {
if (agreed) {
try {
await _authRepository.acceptTnc();
// User agreed, emit Authenticated and then proceed to MPIN check
emit(Authenticated(users));
await _checkMpinAndNavigate();
} catch (e) {
emit(AuthError('Failed to accept TNC: $e'));
}
} else {
// User disagreed, tell UI to navigate to the required screen
emit(NavigateToTncRequiredScreen());
}
}
Future<void> _checkMpinAndNavigate() async {
final mpin = await _secureStorage.read('mpin');
if (mpin == null) {
// No MPIN, tell UI to navigate to MPIN setup
emit(NavigateToMpinSetupScreen());
} else {
// MPIN exists, tell UI to navigate to the dashboard
emit(NavigateToDashboardScreen());
}
}
}

View File

@@ -1,9 +1,12 @@
import 'package:equatable/equatable.dart';
import '../../../data/models/user.dart';
import 'package:kmobile/data/models/user.dart';
import 'package:kmobile/features/auth/models/auth_token.dart';
abstract class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
List<Object> get props => [];
}
class AuthInitial extends AuthState {}
@@ -12,20 +15,37 @@ class AuthLoading extends AuthState {}
class Authenticated extends AuthState {
final List<User> users;
Authenticated(this.users);
const Authenticated(this.users);
@override
List<Object?> get props => [users];
List<Object> get props => [users];
}
class Unauthenticated extends AuthState {}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
const AuthError(this.message);
@override
List<Object?> get props => [message];
List<Object> get props => [message];
}
// --- New States for Navigation and Dialog ---
// State to indicate that the TNC dialog needs to be shown
class ShowTncDialog extends AuthState {
final AuthToken authToken;
final List<User> users;
const ShowTncDialog(this.authToken, this.users);
@override
List<Object> get props => [authToken, users];
}
// States to trigger specific navigations from the UI
class NavigateToTncRequiredScreen extends AuthState {}
class NavigateToMpinSetupScreen extends AuthState {}
class NavigateToDashboardScreen extends AuthState {}

View File

@@ -6,18 +6,22 @@ import 'package:equatable/equatable.dart';
class AuthToken extends Equatable {
final String accessToken;
final DateTime expiresAt;
final bool tnc;
const AuthToken({
required this.accessToken,
required this.expiresAt,
required this.tnc,
});
factory AuthToken.fromJson(Map<String, dynamic> json) {
return AuthToken(
accessToken: json['token'],
expiresAt: _decodeExpiryFromToken(json['token']),
);
}
factory AuthToken.fromJson(Map<String, dynamic> json) {
final token = json['token'];
return AuthToken(
accessToken: token,
expiresAt: _decodeExpiryFromToken(token), // Keep existing method for expiry
tnc: _decodeTncFromToken(token), // Use new method for tnc
);
}
static DateTime _decodeExpiryFromToken(String token) {
try {
@@ -41,9 +45,33 @@ class AuthToken extends Equatable {
return DateTime.now().add(const Duration(hours: 1));
}
}
static bool _decodeTncFromToken(String token) {
try {
final parts = token.split('.');
if (parts.length != 3) {
throw Exception('Invalid JWT format for TNC decoding');
}
final payload = parts[1];
String normalized = base64Url.normalize(payload);
final payloadMap = json.decode(utf8.decode(base64Url.decode(normalized)));
if (payloadMap is! Map<String, dynamic> || !payloadMap.containsKey('tnc')) {
// If 'tnc' is not present in the payload, default to false
return false;
}
// Assuming 'tnc' is directly a boolean in the JWT payload
return payloadMap['tnc'] as bool;
} catch (e) {
log('Error decoding tnc from token: $e');
// Default to false if decoding fails or 'tnc' is not found/invalid
return false;
}
}
bool get isExpired => DateTime.now().isAfter(expiresAt);
@override
List<Object> get props => [accessToken, expiresAt];
List<Object> get props => [accessToken, expiresAt, tnc];
}

View File

@@ -1,12 +1,11 @@
import '../../../l10n/app_localizations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kmobile/di/injection.dart';
import 'package:kmobile/app.dart';
import 'package:kmobile/features/auth/screens/mpin_screen.dart';
import 'package:kmobile/features/auth/screens/set_password_screen.dart';
import 'package:kmobile/security/secure_storage.dart';
import '../../../app.dart';
import 'package:kmobile/features/auth/screens/tnc_required_screen.dart';
import 'package:kmobile/widgets/tnc_dialog.dart';
import '../../../l10n/app_localizations.dart';
import 'package:flutter/material.dart';
import '../controllers/auth_cubit.dart';
import '../controllers/auth_state.dart';
@@ -23,7 +22,6 @@ class LoginScreenState extends State<LoginScreen>
final _customerNumberController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
//bool _showWelcome = true;
@override
void dispose() {
@@ -44,36 +42,51 @@ class LoginScreenState extends State<LoginScreen>
@override
Widget build(BuildContext context) {
return Scaffold(
// appBar: AppBar(title: const Text('Login')),
body: BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) async {
if (state is Authenticated) {
final storage = getIt<SecureStorage>();
final mpin = await storage.read('mpin');
if (!context.mounted) return;
if (mpin == null) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => MPinScreen(
mode: MPinMode.set,
onCompleted: (_) {
Navigator.of(
context,
rootNavigator: true,
).pushReplacement(
MaterialPageRoute(
builder: (_) => const NavigationScaffold(),
),
);
},
),
),
);
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const NavigationScaffold()),
);
if (state is ShowTncDialog) {
// The dialog now returns a boolean for the 'disagree' case,
// or it completes when the 'proceed' action is finished.
final agreed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => TncDialog(
onProceed: () async {
// This function is passed to the dialog.
// It calls the cubit and completes when the cubit's work is done.
await context
.read<AuthCubit>()
.onTncDialogResult(true, state.authToken, state.users);
},
),
);
// If 'agreed' is false, it means the user clicked 'Disagree'.
if (agreed == false) {
if (!context.mounted) return;
context
.read<AuthCubit>()
.onTncDialogResult(false, state.authToken, state.users);
}
} else if (state is NavigateToTncRequiredScreen) {
Navigator.of(context).pushNamed(TncRequiredScreen.routeName);
} else if (state is NavigateToMpinSetupScreen) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => MPinScreen(
mode: MPinMode.set,
onCompleted: (_) {
Navigator.of(context, rootNavigator: true).pushReplacement(
MaterialPageRoute(builder: (_) => const NavigationScaffold()),
);
},
),
),
);
} else if (state is NavigateToDashboardScreen) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const NavigationScaffold()),
);
} else if (state is AuthError) {
if (state.message == 'MIGRATED_USER_HAS_NO_PASSWORD') {
Navigator.of(context).push(MaterialPageRoute(
@@ -87,6 +100,7 @@ class LoginScreenState extends State<LoginScreen>
}
},
builder: (context, state) {
// The commented out section is removed for clarity, the logic is now above.
return Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
@@ -107,7 +121,6 @@ class LoginScreenState extends State<LoginScreen>
},
),
const SizedBox(height: 16),
// Title
Text(
AppLocalizations.of(context).kccb,
style: TextStyle(
@@ -117,12 +130,10 @@ class LoginScreenState extends State<LoginScreen>
),
),
const SizedBox(height: 48),
TextFormField(
controller: _customerNumberController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).customerNumber,
// prefixIcon: Icon(Icons.person),
border: const OutlineInputBorder(),
isDense: true,
filled: true,
@@ -147,7 +158,6 @@ class LoginScreenState extends State<LoginScreen>
},
),
const SizedBox(height: 24),
// Password
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
@@ -189,7 +199,6 @@ class LoginScreenState extends State<LoginScreen>
},
),
const SizedBox(height: 24),
//Login Button
SizedBox(
width: 250,
child: ElevatedButton(
@@ -216,40 +225,7 @@ class LoginScreenState extends State<LoginScreen>
),
),
),
const SizedBox(height: 15),
// Padding(
// padding: const EdgeInsets.symmetric(vertical: 16),
// child: Row(
// children: [
// const Expanded(child: Divider()),
// Padding(
// padding: const EdgeInsets.symmetric(horizontal: 8),
// child: Text(AppLocalizations.of(context).or),
// ),
// //const Expanded(child: Divider()),
// ],
// ),
// ),
const SizedBox(height: 25),
// Register Button
// SizedBox(
// width: 250,
// child: ElevatedButton(
// //disable until registration is implemented
// onPressed: null,
// style: OutlinedButton.styleFrom(
// shape: const StadiumBorder(),
// padding: const EdgeInsets.symmetric(vertical: 16),
// backgroundColor: Theme.of(context).colorScheme.primary,
// foregroundColor: Theme.of(context).colorScheme.onPrimary,
// ),
// child: Text(AppLocalizations.of(context).register,
// style: TextStyle(color: Theme.of(context).colorScheme.onPrimary),),
// ),
// ),
],
),
),

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
class TncRequiredScreen extends StatelessWidget { // Renamed class
const TncRequiredScreen({Key? key}) : super(key: key);
static const routeName = '/tnc-required';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Terms and Conditions'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'You must accept the Terms and Conditions to use the application.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
// This will take the user back to the previous screen
Navigator.of(context).pop();
},
child: const Text('Go Back'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:kmobile/features/auth/screens/tnc_required_screen.dart';
class TncDialog extends StatefulWidget {
// Add a callback function for when the user proceeds
final Future<void> Function() onProceed;
const TncDialog({Key? key, required this.onProceed}) : super(key: key);
@override
_TncDialogState createState() => _TncDialogState();
}
class _TncDialogState extends State<TncDialog> {
bool _isAgreed = false;
bool _isLoading = false;
void _handleProceed() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
});
// Call the provided onProceed function, which will trigger the cubit
await widget.onProceed();
// The dialog will be dismissed by the navigation that happens in the BlocListener
// so we don't need to pop here. If for some reason it's still visible,
// we can add a mounted check and pop.
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Terms and Conditions'),
content: SingleChildScrollView(
child: _isLoading
? const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Please read and accept our terms and conditions to continue. '
'This is a placeholder for the actual terms and conditions text.'),
const SizedBox(height: 16),
Row(
children: [
Checkbox(
value: _isAgreed,
onChanged: (bool? value) {
setState(() {
_isAgreed = value ?? false;
});
},
),
const Flexible(
child: Text('I agree to the Terms and Conditions')),
],
),
],
),
),
actions: [
TextButton(
// Disable button while loading
onPressed: _isLoading ? null : () {
// Pop with false to indicate disagreement
Navigator.of(context).pop(false);
},
child: const Text('Disagree'),
),
ElevatedButton(
// Disable button if not agreed or while loading
onPressed: _isAgreed && !_isLoading ? _handleProceed : null,
child: const Text('Proceed'),
),
],
);
}
}