login finish
This commit is contained in:
parent
77e94b5ec0
commit
b62d4c56ea
@ -2,5 +2,5 @@ class AppConfig {
|
|||||||
const AppConfig._();
|
const AppConfig._();
|
||||||
|
|
||||||
static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443';
|
static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443';
|
||||||
static const String eReceiptSecret = 'y812J21lhha11OS';
|
static const String apiSecret = 'y812J21lhha11OS';
|
||||||
}
|
}
|
||||||
|
|||||||
93
lib/core/network/auth_http_client.dart
Normal file
93
lib/core/network/auth_http_client.dart
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
typedef AccessTokenProvider = String? Function();
|
||||||
|
typedef RefreshAccessToken = Future<String?> Function();
|
||||||
|
typedef RequestUriPredicate = bool Function(Uri uri);
|
||||||
|
|
||||||
|
class AuthHttpClient extends http.BaseClient {
|
||||||
|
AuthHttpClient({
|
||||||
|
required http.Client innerClient,
|
||||||
|
required AccessTokenProvider accessTokenProvider,
|
||||||
|
required RefreshAccessToken refreshAccessToken,
|
||||||
|
RequestUriPredicate? shouldAttachToken,
|
||||||
|
RequestUriPredicate? shouldRefreshOnUnauthorized,
|
||||||
|
}) : _innerClient = innerClient,
|
||||||
|
_accessTokenProvider = accessTokenProvider,
|
||||||
|
_refreshAccessToken = refreshAccessToken,
|
||||||
|
_shouldAttachToken = shouldAttachToken ?? _always,
|
||||||
|
_shouldRefreshOnUnauthorized =
|
||||||
|
shouldRefreshOnUnauthorized ?? _always;
|
||||||
|
|
||||||
|
final http.Client _innerClient;
|
||||||
|
final AccessTokenProvider _accessTokenProvider;
|
||||||
|
final RefreshAccessToken _refreshAccessToken;
|
||||||
|
final RequestUriPredicate _shouldAttachToken;
|
||||||
|
final RequestUriPredicate _shouldRefreshOnUnauthorized;
|
||||||
|
|
||||||
|
Future<String?>? _ongoingRefresh;
|
||||||
|
|
||||||
|
static bool _always(Uri _) => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||||
|
if (_shouldAttachToken(request.url)) {
|
||||||
|
final token = _accessTokenProvider();
|
||||||
|
if (token != null &&
|
||||||
|
token.trim().isNotEmpty &&
|
||||||
|
!request.headers.containsKey('Authorization')) {
|
||||||
|
request.headers['Authorization'] = 'Bearer ${token.trim()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final firstResponse = await _innerClient.send(request);
|
||||||
|
if (firstResponse.statusCode != 401 ||
|
||||||
|
!_shouldRefreshOnUnauthorized(request.url) ||
|
||||||
|
request is! http.Request) {
|
||||||
|
return firstResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
final refreshedToken = await _refreshTokenWithLock();
|
||||||
|
if (refreshedToken == null || refreshedToken.trim().isEmpty) {
|
||||||
|
return firstResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
await firstResponse.stream.drain();
|
||||||
|
final retryRequest = _cloneRequest(request);
|
||||||
|
retryRequest.headers['Authorization'] = 'Bearer ${refreshedToken.trim()}';
|
||||||
|
return _innerClient.send(retryRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() {
|
||||||
|
_innerClient.close();
|
||||||
|
super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _refreshTokenWithLock() async {
|
||||||
|
final inFlight = _ongoingRefresh;
|
||||||
|
if (inFlight != null) {
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextRefresh = _refreshAccessToken();
|
||||||
|
_ongoingRefresh = nextRefresh;
|
||||||
|
try {
|
||||||
|
return await nextRefresh;
|
||||||
|
} finally {
|
||||||
|
_ongoingRefresh = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Request _cloneRequest(http.Request original) {
|
||||||
|
final cloned = http.Request(original.method, original.url)
|
||||||
|
..followRedirects = original.followRedirects
|
||||||
|
..maxRedirects = original.maxRedirects
|
||||||
|
..persistentConnection = original.persistentConnection
|
||||||
|
..encoding = original.encoding
|
||||||
|
..bodyBytes = original.bodyBytes;
|
||||||
|
cloned.headers.addAll(original.headers);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,7 +48,7 @@ class LoggingHttpClient extends http.BaseClient {
|
|||||||
void _logRequest(http.BaseRequest request, String? requestBody) {
|
void _logRequest(http.BaseRequest request, String? requestBody) {
|
||||||
final safeHeaders = _sanitizeHeaders(request.headers);
|
final safeHeaders = _sanitizeHeaders(request.headers);
|
||||||
debugPrint('[API][REQUEST] ${request.method} ${request.url}');
|
debugPrint('[API][REQUEST] ${request.method} ${request.url}');
|
||||||
debugPrint('[API][REQUEST][HEADERS] ${request.headers}');
|
debugPrint('[API][REQUEST][HEADERS] $safeHeaders');
|
||||||
if (requestBody != null && requestBody.isNotEmpty) {
|
if (requestBody != null && requestBody.isNotEmpty) {
|
||||||
debugPrint('[API][REQUEST][BODY] $requestBody');
|
debugPrint('[API][REQUEST][BODY] $requestBody');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,18 +2,17 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
|
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
|
||||||
import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart';
|
import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
class ApiAuthRepository implements AuthRepository {
|
class ApiAuthRepository implements AuthRepository {
|
||||||
ApiAuthRepository({
|
ApiAuthRepository({
|
||||||
required this.baseUrl,
|
required this.baseUrl,
|
||||||
required this.eReceiptSecret,
|
required this.apiSecret,
|
||||||
http.Client? client,
|
http.Client? client,
|
||||||
}) : _client = client ?? http.Client();
|
}) : _client = client ?? http.Client();
|
||||||
|
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
final String eReceiptSecret;
|
final String apiSecret;
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -26,33 +25,56 @@ class ApiAuthRepository implements AuthRepository {
|
|||||||
'username': username.trim().toLowerCase(),
|
'username': username.trim().toLowerCase(),
|
||||||
'password': password,
|
'password': password,
|
||||||
});
|
});
|
||||||
final encodedApiKey = _generateApiKey(requestBody);
|
|
||||||
|
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
uri,
|
uri,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-api-key': encodedApiKey,
|
'x-api-key': apiSecret,
|
||||||
},
|
},
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
final data = _asMap(response.body);
|
return _parseUserFromResponse(
|
||||||
final apiUsername = (data['username'] ?? data['email'])?.toString();
|
response.body,
|
||||||
return LoginUser(
|
fallbackUsername: username.trim(),
|
||||||
username: apiUsername?.trim().isNotEmpty == true
|
|
||||||
? apiUsername!.trim()
|
|
||||||
: username.trim(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw Exception(_extractErrorMessage(response.body));
|
throw Exception(_extractErrorMessage(response.body));
|
||||||
}
|
}
|
||||||
|
|
||||||
String _generateApiKey(String bodyString) {
|
@override
|
||||||
final dataToHash = '$bodyString$eReceiptSecret';
|
Future<LoginUser> refreshSession({
|
||||||
return sha256.convert(utf8.encode(dataToHash)).toString();
|
required String username,
|
||||||
|
required String refreshToken,
|
||||||
|
required String role,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/receipt/auth/refresh-token');
|
||||||
|
final requestBody = jsonEncode({
|
||||||
|
'refreshToken': refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await _client.post(
|
||||||
|
uri,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiSecret,
|
||||||
|
},
|
||||||
|
body: requestBody,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
return _parseUserFromResponse(
|
||||||
|
response.body,
|
||||||
|
fallbackUsername: username,
|
||||||
|
fallbackRefreshToken: refreshToken,
|
||||||
|
fallbackRole: role,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception(_extractErrorMessage(response.body));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _asMap(String body) {
|
Map<String, dynamic> _asMap(String body) {
|
||||||
@ -75,4 +97,62 @@ class ApiAuthRepository implements AuthRepository {
|
|||||||
}
|
}
|
||||||
return message.toString();
|
return message.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LoginUser _parseUserFromResponse(
|
||||||
|
String body, {
|
||||||
|
required String fallbackUsername,
|
||||||
|
String? fallbackRefreshToken,
|
||||||
|
String? fallbackRole,
|
||||||
|
}) {
|
||||||
|
final data = _asMap(body);
|
||||||
|
final payload = _extractPayload(data);
|
||||||
|
final apiUsername = _pickString(payload, const ['username', 'email']);
|
||||||
|
final token = _pickString(payload, const ['token', 'accessToken']);
|
||||||
|
final refreshToken = _pickString(
|
||||||
|
payload,
|
||||||
|
const ['refreshToken', 'refresh_token'],
|
||||||
|
);
|
||||||
|
final role = _pickString(payload, const ['role']);
|
||||||
|
|
||||||
|
if (token == null) {
|
||||||
|
throw Exception('Auth response is missing token');
|
||||||
|
}
|
||||||
|
|
||||||
|
final effectiveRefreshToken = refreshToken ?? fallbackRefreshToken;
|
||||||
|
if (effectiveRefreshToken == null) {
|
||||||
|
throw Exception('Auth response is missing refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
final effectiveRole = role ?? fallbackRole;
|
||||||
|
if (effectiveRole == null || effectiveRole.trim().isEmpty) {
|
||||||
|
throw Exception('Auth response is missing role');
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoginUser(
|
||||||
|
username: apiUsername?.trim().isNotEmpty == true
|
||||||
|
? apiUsername!.trim()
|
||||||
|
: fallbackUsername.trim(),
|
||||||
|
token: token,
|
||||||
|
refreshToken: effectiveRefreshToken,
|
||||||
|
role: effectiveRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _extractPayload(Map<String, dynamic> data) {
|
||||||
|
final dynamic nested = data['data'] ?? data['result'] ?? data['payload'];
|
||||||
|
if (nested is Map<String, dynamic>) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _pickString(Map<String, dynamic> data, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = data[key];
|
||||||
|
if (value != null && value.toString().trim().isNotEmpty) {
|
||||||
|
return value.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,31 @@ class MockAuthRepository implements AuthRepository {
|
|||||||
throw Exception('Password must be at least 6 characters.');
|
throw Exception('Password must be at least 6 characters.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return LoginUser(username: username.trim());
|
return LoginUser(
|
||||||
|
username: username.trim(),
|
||||||
|
token: 'mock-access-token',
|
||||||
|
refreshToken: 'mock-refresh-token',
|
||||||
|
role: username.trim().toLowerCase() == 'admin' ? 'admin' : 'user',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LoginUser> refreshSession({
|
||||||
|
required String username,
|
||||||
|
required String refreshToken,
|
||||||
|
required String role,
|
||||||
|
}) async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
if (refreshToken.trim().isEmpty) {
|
||||||
|
throw Exception('Refresh token is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoginUser(
|
||||||
|
username: username.trim(),
|
||||||
|
token: 'mock-access-token-refreshed',
|
||||||
|
refreshToken: 'mock-refresh-token-refreshed',
|
||||||
|
role: role,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,15 @@
|
|||||||
class LoginUser {
|
class LoginUser {
|
||||||
const LoginUser({required this.username});
|
const LoginUser({
|
||||||
|
required this.username,
|
||||||
|
required this.token,
|
||||||
|
required this.refreshToken,
|
||||||
|
required this.role,
|
||||||
|
});
|
||||||
|
|
||||||
final String username;
|
final String username;
|
||||||
|
final String token;
|
||||||
|
final String refreshToken;
|
||||||
|
final String role;
|
||||||
|
|
||||||
|
bool get isAdmin => role.toLowerCase() == 'admin';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,4 +2,10 @@ import 'package:e_receipt_mobile/domain/entities/login_user.dart';
|
|||||||
|
|
||||||
abstract class AuthRepository {
|
abstract class AuthRepository {
|
||||||
Future<LoginUser> login({required String username, required String password});
|
Future<LoginUser> login({required String username, required String password});
|
||||||
|
|
||||||
|
Future<LoginUser> refreshSession({
|
||||||
|
required String username,
|
||||||
|
required String refreshToken,
|
||||||
|
required String role,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
19
lib/presentation/auth/session_controller.dart
Normal file
19
lib/presentation/auth/session_controller.dart
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
final sessionControllerProvider =
|
||||||
|
StateNotifierProvider<SessionController, LoginUser?>((ref) {
|
||||||
|
return SessionController();
|
||||||
|
});
|
||||||
|
|
||||||
|
class SessionController extends StateNotifier<LoginUser?> {
|
||||||
|
SessionController() : super(null);
|
||||||
|
|
||||||
|
void setUser(LoginUser user) {
|
||||||
|
state = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearUser() {
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,12 +5,14 @@ Widget roundedInput({
|
|||||||
required String hint,
|
required String hint,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
bool isPassword = false,
|
bool isPassword = false,
|
||||||
|
String? Function(String?)? validator,
|
||||||
}) {
|
}) {
|
||||||
return RoundedInput(
|
return RoundedInput(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
hint: hint,
|
hint: hint,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
isPassword: isPassword,
|
isPassword: isPassword,
|
||||||
|
validator: validator,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,12 +23,14 @@ class RoundedInput extends StatefulWidget {
|
|||||||
required this.hint,
|
required this.hint,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
this.isPassword = false,
|
this.isPassword = false,
|
||||||
|
this.validator,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final String hint;
|
final String hint;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final bool isPassword;
|
final bool isPassword;
|
||||||
|
final String? Function(String?)? validator;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RoundedInput> createState() => _RoundedInputState();
|
State<RoundedInput> createState() => _RoundedInputState();
|
||||||
@ -43,9 +47,10 @@ class _RoundedInputState extends State<RoundedInput> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return TextField(
|
return TextFormField(
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
obscureText: _obscured,
|
obscureText: _obscured,
|
||||||
|
validator: widget.validator,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: widget.hint,
|
hintText: widget.hint,
|
||||||
prefixIcon: Icon(widget.icon, color: Colors.grey),
|
prefixIcon: Icon(widget.icon, color: Colors.grey),
|
||||||
|
|||||||
@ -1,17 +1,28 @@
|
|||||||
|
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({required this.user, super.key});
|
||||||
|
|
||||||
|
final LoginUser user;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final role = user.role.toLowerCase();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Home'),
|
title: Text(role == 'admin' ? 'Admin Home' : 'User Home'),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: const Center(
|
body: Center(
|
||||||
child: Text('Welcome to Home Screen'),
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Welcome ${user.username}'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Role: ${user.role}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,17 +43,18 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (next.successMessage != null &&
|
if (next.successMessage != null &&
|
||||||
|
next.user != null &&
|
||||||
previous?.successMessage != next.successMessage) {
|
previous?.successMessage != next.successMessage) {
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => const HomeScreen(),
|
builder: (_) => HomeScreen(user: next.user!),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.blue,
|
backgroundColor: Colors.white,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
@ -79,10 +80,34 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
//Username
|
//Username
|
||||||
roundedInput(controller: _usernameController, hint: "Username", icon: Icons.person),
|
roundedInput(
|
||||||
|
controller: _usernameController,
|
||||||
|
hint: "Username",
|
||||||
|
icon: Icons.person,
|
||||||
|
validator: (value) {
|
||||||
|
if ((value ?? '').trim().isEmpty) {
|
||||||
|
return 'Username is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
//Password
|
//Password
|
||||||
roundedInput(controller: _passwordController, hint: "Password", icon: Icons.lock, isPassword: true),
|
roundedInput(
|
||||||
|
controller: _passwordController,
|
||||||
|
hint: "Password",
|
||||||
|
icon: Icons.lock,
|
||||||
|
isPassword: true,
|
||||||
|
validator: (value) {
|
||||||
|
if ((value ?? '').isEmpty) {
|
||||||
|
return 'Password is required';
|
||||||
|
}
|
||||||
|
if (value!.length < 6) {
|
||||||
|
return 'Password must be at least 6 characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: state.isLoading
|
onPressed: state.isLoading
|
||||||
|
|||||||
@ -1,18 +1,25 @@
|
|||||||
|
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
|
||||||
|
|
||||||
class LoginState {
|
class LoginState {
|
||||||
const LoginState({
|
const LoginState({
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.successMessage,
|
this.successMessage,
|
||||||
|
this.user,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? successMessage;
|
final String? successMessage;
|
||||||
|
final LoginUser? user;
|
||||||
|
|
||||||
|
static const Object _unset = Object();
|
||||||
|
|
||||||
LoginState copyWith({
|
LoginState copyWith({
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? successMessage,
|
String? successMessage,
|
||||||
|
Object? user = _unset,
|
||||||
bool clearError = false,
|
bool clearError = false,
|
||||||
bool clearSuccess = false,
|
bool clearSuccess = false,
|
||||||
}) {
|
}) {
|
||||||
@ -22,6 +29,7 @@ class LoginState {
|
|||||||
successMessage: clearSuccess
|
successMessage: clearSuccess
|
||||||
? null
|
? null
|
||||||
: successMessage ?? this.successMessage,
|
: successMessage ?? this.successMessage,
|
||||||
|
user: identical(user, _unset) ? this.user : user as LoginUser?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,50 +1,102 @@
|
|||||||
import 'package:e_receipt_mobile/core/config/app_config.dart';
|
import 'package:e_receipt_mobile/core/config/app_config.dart';
|
||||||
|
import 'package:e_receipt_mobile/core/network/auth_http_client.dart';
|
||||||
import 'package:e_receipt_mobile/core/network/logging_http_client.dart';
|
import 'package:e_receipt_mobile/core/network/logging_http_client.dart';
|
||||||
import 'package:e_receipt_mobile/data/repositories/api_auth_repository.dart';
|
import 'package:e_receipt_mobile/data/repositories/api_auth_repository.dart';
|
||||||
import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart';
|
import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart';
|
||||||
|
import 'package:e_receipt_mobile/presentation/auth/session_controller.dart';
|
||||||
import 'package:e_receipt_mobile/presentation/login/login_state.dart';
|
import 'package:e_receipt_mobile/presentation/login/login_state.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
final httpClientProvider = Provider<http.Client>((ref) {
|
final rawHttpClientProvider = Provider<http.Client>((ref) {
|
||||||
final client = LoggingHttpClient();
|
final client = LoggingHttpClient();
|
||||||
ref.onDispose(client.close);
|
ref.onDispose(client.close);
|
||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
|
||||||
|
final client = AuthHttpClient(
|
||||||
|
innerClient: LoggingHttpClient(),
|
||||||
|
accessTokenProvider: () => ref.read(sessionControllerProvider)?.token,
|
||||||
|
shouldRefreshOnUnauthorized: (uri) =>
|
||||||
|
!uri.path.endsWith('/receipt/auth/login') &&
|
||||||
|
!uri.path.endsWith('/receipt/auth/refresh-token'),
|
||||||
|
refreshAccessToken: () async {
|
||||||
|
final sessionUser = ref.read(sessionControllerProvider);
|
||||||
|
if (sessionUser == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final refreshedUser = await ref.read(authRepositoryProvider).refreshSession(
|
||||||
|
username: sessionUser.username,
|
||||||
|
refreshToken: sessionUser.refreshToken,
|
||||||
|
role: sessionUser.role,
|
||||||
|
);
|
||||||
|
ref.read(sessionControllerProvider.notifier).setUser(refreshedUser);
|
||||||
|
return refreshedUser.token;
|
||||||
|
} catch (_) {
|
||||||
|
ref.read(sessionControllerProvider.notifier).clearUser();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ref.onDispose(client.close);
|
||||||
|
return client;
|
||||||
|
});
|
||||||
|
|
||||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||||
return ApiAuthRepository(
|
return ApiAuthRepository(
|
||||||
baseUrl: AppConfig.apiBaseUrl,
|
baseUrl: AppConfig.apiBaseUrl,
|
||||||
eReceiptSecret: AppConfig.eReceiptSecret,
|
apiSecret: AppConfig.apiSecret,
|
||||||
client: ref.watch(httpClientProvider),
|
client: ref.watch(rawHttpClientProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final loginViewModelProvider =
|
final loginViewModelProvider =
|
||||||
StateNotifierProvider<LoginViewModel, LoginState>((ref) {
|
StateNotifierProvider<LoginViewModel, LoginState>((ref) {
|
||||||
return LoginViewModel(ref.watch(authRepositoryProvider));
|
return LoginViewModel(
|
||||||
|
ref.watch(authRepositoryProvider),
|
||||||
|
ref.watch(sessionControllerProvider.notifier),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
class LoginViewModel extends StateNotifier<LoginState> {
|
class LoginViewModel extends StateNotifier<LoginState> {
|
||||||
LoginViewModel(this._authRepository) : super(const LoginState());
|
LoginViewModel(this._authRepository, this._sessionController)
|
||||||
|
: super(const LoginState());
|
||||||
|
|
||||||
final AuthRepository _authRepository;
|
final AuthRepository _authRepository;
|
||||||
|
final SessionController _sessionController;
|
||||||
|
|
||||||
Future<void> login({required String username, required String password}) async {
|
Future<void> login({required String username, required String password}) async {
|
||||||
|
final trimmedUsername = username.trim();
|
||||||
|
if (trimmedUsername.isEmpty || password.isEmpty) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: 'Username and password are required',
|
||||||
|
clearSuccess: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
clearError: true,
|
clearError: true,
|
||||||
clearSuccess: true,
|
clearSuccess: true,
|
||||||
|
user: null,
|
||||||
);
|
);
|
||||||
|
_sessionController.clearUser();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final user = await _authRepository.login(
|
final user = await _authRepository.login(
|
||||||
username: username,
|
username: trimmedUsername,
|
||||||
password: password,
|
password: password,
|
||||||
);
|
);
|
||||||
|
_sessionController.setUser(user);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
successMessage: 'Welcome ${user.username}',
|
user: user,
|
||||||
|
successMessage: 'Welcome ${user.username} (${user.role})',
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
|
|||||||
@ -41,14 +41,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
crypto:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: crypto
|
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.7"
|
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -36,7 +36,6 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_riverpod: ^2.6.1
|
flutter_riverpod: ^2.6.1
|
||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
crypto: ^3.0.6
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user