diff --git a/flutter_01.png b/flutter_01.png new file mode 100644 index 0000000..11e2314 Binary files /dev/null and b/flutter_01.png differ diff --git a/lib/api/services/auth_service.dart b/lib/api/services/auth_service.dart index 1486645..333cd42 100644 --- a/lib/api/services/auth_service.dart +++ b/lib/api/services/auth_service.dart @@ -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()}'); + } + } +} \ No newline at end of file diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 728a57d..a9e16b5 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -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( diff --git a/lib/data/repositories/auth_repository.dart b/lib/data/repositories/auth_repository.dart index 9a96026..860d92b 100644 --- a/lib/data/repositories/auth_repository.dart +++ b/lib/data/repositories/auth_repository.dart @@ -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> login(String customerNo, String password) async { + Future<(List, 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 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 clearAuthTokens() async { @@ -56,13 +58,27 @@ class AuthRepository { Future _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 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; + } + } } diff --git a/lib/di/injection.dart b/lib/di/injection.dart index d28baa4..ca5016e 100644 --- a/lib/di/injection.dart +++ b/lib/di/injection.dart @@ -62,7 +62,7 @@ Future setupDependencies() async { // Register controllers/cubits getIt.registerFactory( - () => AuthCubit(getIt(), getIt())); + () => AuthCubit(getIt(), getIt(), getIt())); } Dio _createDioClient() { diff --git a/lib/features/auth/controllers/auth_cubit.dart b/lib/features/auth/controllers/auth_cubit.dart index 0140a9c..1195548 100644 --- a/lib/features/auth/controllers/auth_cubit.dart +++ b/lib/features/auth/controllers/auth_cubit.dart @@ -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 { 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 { Future 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 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 onTncDialogResult( + bool agreed, AuthToken authToken, List 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 _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()); + } + } } diff --git a/lib/features/auth/controllers/auth_state.dart b/lib/features/auth/controllers/auth_state.dart index abed153..f9fbec7 100644 --- a/lib/features/auth/controllers/auth_state.dart +++ b/lib/features/auth/controllers/auth_state.dart @@ -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 get props => []; + List get props => []; } class AuthInitial extends AuthState {} @@ -12,20 +15,37 @@ class AuthLoading extends AuthState {} class Authenticated extends AuthState { final List users; - - Authenticated(this.users); + const Authenticated(this.users); @override - List get props => [users]; + List get props => [users]; } class Unauthenticated extends AuthState {} class AuthError extends AuthState { final String message; - - AuthError(this.message); + const AuthError(this.message); @override - List get props => [message]; + List 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 users; + const ShowTncDialog(this.authToken, this.users); + + @override + List get props => [authToken, users]; +} + +// States to trigger specific navigations from the UI +class NavigateToTncRequiredScreen extends AuthState {} + +class NavigateToMpinSetupScreen extends AuthState {} + +class NavigateToDashboardScreen extends AuthState {} \ No newline at end of file diff --git a/lib/features/auth/models/auth_token.dart b/lib/features/auth/models/auth_token.dart index ad1c56d..586dc49 100644 --- a/lib/features/auth/models/auth_token.dart +++ b/lib/features/auth/models/auth_token.dart @@ -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 json) { - return AuthToken( - accessToken: json['token'], - expiresAt: _decodeExpiryFromToken(json['token']), - ); - } + factory AuthToken.fromJson(Map 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 || !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 get props => [accessToken, expiresAt]; + List get props => [accessToken, expiresAt, tnc]; } diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart index ccad012..9ab4aa8 100644 --- a/lib/features/auth/screens/login_screen.dart +++ b/lib/features/auth/screens/login_screen.dart @@ -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 final _customerNumberController = TextEditingController(); final _passwordController = TextEditingController(); bool _obscurePassword = true; - //bool _showWelcome = true; @override void dispose() { @@ -44,36 +42,51 @@ class LoginScreenState extends State @override Widget build(BuildContext context) { return Scaffold( - // appBar: AppBar(title: const Text('Login')), body: BlocConsumer( listener: (context, state) async { - if (state is Authenticated) { - final storage = getIt(); - 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( + 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() + .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() + .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 } }, 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 }, ), const SizedBox(height: 16), - // Title Text( AppLocalizations.of(context).kccb, style: TextStyle( @@ -117,12 +130,10 @@ class LoginScreenState extends State ), ), 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 }, ), const SizedBox(height: 24), - // Password TextFormField( controller: _passwordController, obscureText: _obscurePassword, @@ -189,7 +199,6 @@ class LoginScreenState extends State }, ), const SizedBox(height: 24), - //Login Button SizedBox( width: 250, child: ElevatedButton( @@ -216,40 +225,7 @@ class LoginScreenState extends State ), ), ), - 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),), - // ), - // ), ], ), ), diff --git a/lib/features/auth/screens/tnc_required_screen.dart b/lib/features/auth/screens/tnc_required_screen.dart new file mode 100644 index 0000000..23b665a --- /dev/null +++ b/lib/features/auth/screens/tnc_required_screen.dart @@ -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'), + ), + ], + ), + ), + ), + ); + } + } \ No newline at end of file diff --git a/lib/widgets/tnc_dialog.dart b/lib/widgets/tnc_dialog.dart new file mode 100644 index 0000000..a4870e2 --- /dev/null +++ b/lib/widgets/tnc_dialog.dart @@ -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 Function() onProceed; + + const TncDialog({Key? key, required this.onProceed}) : super(key: key); + + @override + _TncDialogState createState() => _TncDialogState(); +} + +class _TncDialogState extends State { + 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'), + ), + ], + ); + } +} \ No newline at end of file