feat: added authentification and redirection

This commit is contained in:
Alzalia 2025-08-08 01:03:48 +02:00
parent 1c9c5ce5fe
commit ef641d4023
24 changed files with 731 additions and 173 deletions

View file

@ -0,0 +1 @@
const apiBasePath = "bal.ueauvergne.fr";

View file

@ -1,8 +1,23 @@
import "package:flutter/widgets.dart";
import "package:nested/nested.dart";
import "package:provider/provider.dart";
import "package:provider/single_child_widget.dart";
import "package:seshat/data/repositories/auth_repository.dart";
import "package:seshat/data/repositories/owner_repository.dart";
import "package:seshat/data/services/api_client.dart";
import "package:seshat/data/services/auth_client.dart";
import "package:seshat/data/services/websocket_client.dart";
List<SingleChildWidget> get providers {
return [Provider(create: (context) => WebsocketClient())];
return [
Provider(create: (context) => AuthClient()),
Provider(create: (context) => ApiClient(authClient: context.read())),
Provider(create: (context) => WebsocketClient()),
Provider(
create: (context) =>
OwnerRepository(apiClient: context.read(), wsClient: context.read()),
),
ChangeNotifierProvider(
create: (context) => AuthRepository(authClient: context.read()),
),
];
}

View file

@ -0,0 +1,43 @@
import 'package:flutter/foundation.dart';
import 'package:seshat/data/services/auth_client.dart';
import 'package:seshat/utils/result.dart';
class AuthRepository extends ChangeNotifier {
AuthRepository({required AuthClient authClient}) : _authClient = authClient;
final AuthClient _authClient;
bool? _isAuthenticated;
Future<bool> get isLoggedIn async {
if (_isAuthenticated != null) {
return _isAuthenticated!;
}
final result = await _authClient.hasValidToken();
switch (result) {
case Ok():
if (result.value) {
return true;
}
return false;
case Error():
return false;
}
}
Future<Result<void>> login(String username, String password) async {
try {
final result = await _authClient.login(username, password);
switch (result) {
case Ok():
_isAuthenticated = true;
return Result.ok(());
case Error():
return Result.error(result.error);
}
} catch (e, stackTrace) {
debugPrintStack(stackTrace: stackTrace);
return Result.error(Exception(e));
}
}
}

View file

@ -14,32 +14,42 @@ class OwnerRepository {
final ApiClient _apiClient;
final WebsocketClient _wsClient;
List<Owner>? _cachedData;
List<Owner>? _cachedOwners;
Future<Result<Owner>> postOwner(
String firstName,
String lastName,
String contact,
) async {
return Result.ok(
Owner(firstName: firstName, lastName: lastName, contact: contact, id: 50),
);
}
Future<Result<List<Owner>>> getOwners() async {
if (_cachedData == null) {
if (_cachedOwners == null) {
final result = await _apiClient.getOwners();
if (result is Ok<List<Owner>>) {
_cachedData = result.value;
_cachedOwners = result.value;
}
return result;
} else {
return Result.ok(_cachedData!);
return Result.ok(_cachedOwners!);
}
}
Stream<Owner> liveOwners() async* {
await for (String data in _wsClient.connect()) {
await for (String data in await _wsClient.connect()) {
Map<String, dynamic> decodedData = jsonDecode(
data,
).cast<Map<String, dynamic>>();
Owner owner = Owner.fromJSON(decodedData);
if (_cachedData == null) {
getOwners();
if (_cachedOwners == null) {
await getOwners();
} else {
_cachedData!.add(owner);
_cachedOwners!.add(owner);
yield* Stream.value(owner);
}
}

View file

@ -1,8 +1,61 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:seshat/config/constants.dart';
import 'package:seshat/data/services/auth_client.dart';
import 'package:seshat/domain/models/owner.dart';
import 'package:seshat/utils/command.dart';
import 'package:seshat/utils/result.dart';
typedef AuthHeaderProvider = String? Function();
class ApiClient {
ApiClient({
String? host,
int? port,
HttpClient Function()? clientFactory,
required AuthClient authClient,
}) : _authClient = authClient;
final AuthClient _authClient;
late final Command0 load;
String? token;
bool isReady = false;
FlutterSecureStorage? _secureStorage;
Future<void> _initStore() async {
_secureStorage ??= const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
}
Future<Result<List<Owner>>> getOwners() async {
return Result.ok(<Owner>[]);
final client = HttpClient();
try {
await _initStore();
final request = await client.getUrl(
Uri.parse("https://$apiBasePath/owners"),
);
final token = await _secureStorage!.read(key: "token");
debugPrint("\n\n\n\nFOUND TOKEN : $token\n\n\n\n");
// await _authHeader(request.headers);
request.headers.add(HttpHeaders.authorizationHeader, "Bearer $token");
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(Utf8Decoder()).join();
final json = jsonDecode(stringData) as List<dynamic>;
return Result.ok(
json.map((element) => Owner.fromJSON(element)).toList(),
);
} else {
return const Result.error(HttpException("Invalid response"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
}

View file

@ -0,0 +1,77 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:seshat/config/constants.dart';
import 'package:seshat/utils/result.dart';
import "package:http/http.dart" as http;
class AuthClient {
AuthClient();
FlutterSecureStorage? _secureStorage;
Future<void> _initStore() async {
_secureStorage ??= const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
}
Future<Result<bool>> hasValidToken() async {
try {
await _initStore();
bool hasToken = await _secureStorage!.containsKey(key: "token");
debugPrint("\n\n\n${hasToken == true} => HAS_TOKEN\n\n\n");
if (hasToken) {
var token = await _secureStorage!.read(key: "token");
var url = Uri.parse("https://$apiBasePath/token-check");
var response = await http.post(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({"token": token}),
);
debugPrint(
"\n\n\n${response.body is String} => ${response.body}\n\n\n",
);
if (response.body == "true") {
return Result.ok(true);
}
}
return Result.ok(false);
} catch (e) {
debugPrint(e.toString());
return Result.error(Exception(e));
}
}
Future<Result<String>> login(String username, String password) async {
var client = http.Client();
try {
await _initStore();
var url = Uri.parse("https://$apiBasePath/auth");
var response = await client.post(
url,
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: jsonEncode({"password": password, "username": username}),
);
if (response.statusCode == 200) {
var json = jsonDecode(response.body);
await _secureStorage!.write(key: "token", value: json["access_token"]);
return Result.ok(json["access_token"]);
} else if (response.statusCode == 401) {
return Result.error(Exception("Wrong credentials"));
} else {
return Result.error(Exception("Token creation error"));
}
} catch (e, stackTrace) {
debugPrint(e.toString());
debugPrintStack(stackTrace: stackTrace);
return Result.error(Exception(e));
} finally {
client.close();
}
}
}

View file

@ -1,10 +1,16 @@
import 'package:seshat/config/constants.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class WebsocketClient {
static const String _host = "ws://bal.ninjdai.fr:3000";
Future<Stream<dynamic>> connect() async {
final channel = WebSocketChannel.connect(
Uri.parse("wss://$apiBasePath/ws"),
);
await channel.ready;
channel.sink.add("json-token: ");
Stream<dynamic> connect() {
final channel = WebSocketChannel.connect(Uri.parse("$_host/ws"));
return channel.stream;
}
}

View file

@ -1,12 +1,15 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:seshat/config/dependencies.dart';
import 'package:seshat/routing/router.dart';
void main() {
Logger.root.level = Level.ALL;
WidgetsFlutterBinding.ensureInitialized();
// runApp(MultiProvider(providers: providers, child: const MyApp()));
runApp(const MyApp());
runApp(MultiProvider(providers: providers, child: const MyApp()));
// runApp(const MyApp());
}
class MyApp extends StatelessWidget {
@ -15,7 +18,7 @@ class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp.router(routerConfig: router());
return MaterialApp.router(routerConfig: router(context.read()));
}
}

View file

@ -1,12 +1,31 @@
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:seshat/data/repositories/auth_repository.dart';
import 'package:seshat/routing/routes.dart';
import 'package:seshat/ui/add_page/view_model/add_view_model.dart';
import 'package:seshat/ui/add_page/widgets/add_page.dart';
import 'package:seshat/ui/auth/viewmodel/login_view_model.dart';
import 'package:seshat/ui/auth/widgets/login_page.dart';
import 'package:seshat/ui/home_page/home_page.dart';
import 'package:seshat/ui/sell_page/sell_page.dart';
GoRouter router() => GoRouter(
GoRouter router(AuthRepository authRepository) => GoRouter(
initialLocation: Routes.add,
redirect: (context, state) async {
final loggedIn = await context.read<AuthRepository>().isLoggedIn;
final logginIn = state.matchedLocation == Routes.login;
if (!loggedIn) {
return Routes.login;
}
if (logginIn) {
return Routes.add;
}
return null;
},
refreshListenable: authRepository,
routes: [
GoRoute(
path: Routes.home,
@ -14,8 +33,10 @@ GoRouter router() => GoRouter(
routes: [
GoRoute(
path: Routes.add,
pageBuilder: (context, state) =>
NoTransitionPage(child: AddPage(viewModel: AddViewModel())),
pageBuilder: (context, state) {
final viewModel = AddViewModel(ownerRepository: context.read());
return NoTransitionPage(child: AddPage(viewModel: viewModel));
},
// routes: [
// GoRoute(path: Routes.addForm),
// GoRoute(path: Routes.addOwner),
@ -26,6 +47,13 @@ GoRouter router() => GoRouter(
path: Routes.sell,
pageBuilder: (context, state) => NoTransitionPage(child: SellPage()),
),
GoRoute(
path: Routes.login,
pageBuilder: (context, state) {
final viewModel = LoginViewModel(authRepository: context.read());
return NoTransitionPage(child: LoginPage(viewModel: viewModel));
},
),
],
),
],

View file

@ -10,4 +10,7 @@ abstract final class Routes {
// ==[ SELL ]==
static const sell = '/sell';
// ==[ AUTH ]==
static const login = '/login';
}

View file

@ -1,12 +1,19 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:seshat/data/repositories/owner_repository.dart';
import 'package:seshat/domain/models/book.dart';
import 'package:seshat/domain/models/owner.dart';
import 'package:seshat/utils/command.dart';
import 'package:seshat/utils/result.dart';
class AddViewModel extends ChangeNotifier {
AddViewModel();
AddViewModel({required OwnerRepository ownerRepository})
: _ownerRepository = ownerRepository {
load = Command0(_load)..execute();
}
final OwnerRepository _ownerRepository;
/*
* ====================
@ -21,37 +28,37 @@ class AddViewModel extends ChangeNotifier {
notifyListeners();
}
final List<Owner> _owners = [];
List<Owner> _owners = [];
List<Owner>? get owners => _owners;
Owner addOwner(String firstName, String lastName, String contact) {
if (_owners.isEmpty) {
_owners.add(
Owner(
firstName: firstName,
lastName: lastName,
contact: contact,
id: 1,
),
);
} else {
_owners.add(
Owner(
firstName: firstName,
lastName: lastName,
contact: contact,
id: _owners.last.id + 1,
),
);
}
notifyListeners();
return Owner(
firstName: firstName,
lastName: lastName,
contact: contact,
id: 0,
Future<Result<Owner>> addOwner(
String firstName,
String lastName,
String contact,
) async {
final result = await _ownerRepository.postOwner(
firstName,
lastName,
contact,
);
switch (result) {
case Ok():
final secondResult = await _ownerRepository.getOwners();
switch (secondResult) {
case Ok():
_owners = secondResult.value;
_currentOwner = result.value;
notifyListeners();
return Result.ok(result.value);
case Error():
return Result.error(secondResult.error);
}
case Error():
return Result.error(result.error);
}
}
/*
@ -87,8 +94,38 @@ class AddViewModel extends ChangeNotifier {
);
}
/// Sens an api request with
/// Sends an api request with
// Result<BookInstance> newBookInstance() {
// };
/*
* =================================
* =====[ COMMAND AND LOADING ]=====
* =================================
*/
late final Command0 load;
bool isLoaded = false;
Future<Result<void>> _load() async {
return await _loadOwners();
}
Future<Result<void>> _loadOwners() async {
final result = await _ownerRepository.getOwners();
switch (result) {
case Ok():
_owners = result.value;
isLoaded = true;
case Error():
debugPrint("Oupsie daysie, ${result.error}");
}
notifyListeners();
_ownerRepository.liveOwners().listen((Owner owner) {
_owners.add(owner);
notifyListeners();
});
return result;
}
}

View file

@ -32,130 +32,137 @@ class _AddPageState extends State<AddPage> {
// builder: (context, screen, child) {
return Scaffold(
bottomNavigationBar: AppNavigationBar(startIndex: 1),
body: Stack(
children: [
ColoredBox(color: Colors.black),
MobileScanner(
controller: controller,
onDetect: (barcodes) async {
if (widget.viewModel.currentOwner == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Attention : vous devez choisir un·e propriétaire",
),
behavior: SnackBarBehavior.floating,
),
);
return;
}
void setPrice(num newPrice) async {
setState(() {
price = newPrice;
});
}
Result<Book> result = await widget.viewModel.scanBook(barcodes);
switch (result) {
case Ok():
await _confirmationDialogBuilder(
context,
setPrice,
controller,
widget.viewModel,
result.value,
);
break;
case Error():
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Erreur : ${result.error}"),
behavior: SnackBarBehavior.floating,
),
);
break;
}
},
),
SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Card(
margin: EdgeInsets.symmetric(horizontal: 50),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListenableBuilder(
listenable: widget.viewModel,
builder: (context, child) => ListTile(
leading: Icon(Icons.person),
title: TextButton(
child: Text(
(widget.viewModel.currentOwner == null)
? "Aucun"
: "${widget.viewModel.currentOwner!.firstName} ${widget.viewModel.currentOwner!.lastName}",
),
onPressed: () => _ownerDialogBuilder(
context,
controller,
widget.viewModel,
),
),
),
),
ListTile(
leading: Icon(Icons.attach_money),
title: TextButton(
child: Text(
(widget.viewModel.askPrice)
? "Demander à chaque fois"
: "Prix libre toujours",
),
onPressed: () {
setState(() {
widget.viewModel.askPrice =
!widget.viewModel.askPrice;
});
},
),
),
],
body: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, child) => switch (widget.viewModel.isLoaded) {
false => CircularProgressIndicator(),
true => Stack(
children: [
ColoredBox(color: Colors.black),
MobileScanner(
controller: controller,
onDetect: (barcodes) async {
if (widget.viewModel.currentOwner == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Attention : vous devez choisir un·e propriétaire",
),
behavior: SnackBarBehavior.floating,
),
),
),
SizedBox(height: 100),
SvgPicture.asset('assets/scan-overlay.svg'),
],
);
return;
}
void setPrice(num newPrice) async {
setState(() {
price = newPrice;
});
}
Result<Book> result = await widget.viewModel.scanBook(
barcodes,
);
switch (result) {
case Ok():
await _confirmationDialogBuilder(
context,
setPrice,
controller,
widget.viewModel,
result.value,
);
break;
case Error():
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Erreur : ${result.error}"),
behavior: SnackBarBehavior.floating,
),
);
break;
}
},
),
),
),
SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Center(
child: TextButton(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(theme.cardColor),
),
onPressed: () => _formDialogBuilder(
context,
controller,
widget.viewModel,
),
child: Text("Enregistrer manuellement"),
SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Card(
margin: EdgeInsets.symmetric(horizontal: 50),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(Icons.person),
title: TextButton(
child: Text(
(widget.viewModel.currentOwner == null)
? "Aucun"
: "${widget.viewModel.currentOwner!.firstName} ${widget.viewModel.currentOwner!.lastName}",
),
onPressed: () => _ownerDialogBuilder(
context,
controller,
widget.viewModel,
),
),
),
ListTile(
leading: Icon(Icons.attach_money),
title: TextButton(
child: Text(
(widget.viewModel.askPrice)
? "Demander à chaque fois"
: "Prix libre toujours",
),
onPressed: () {
setState(() {
widget.viewModel.askPrice =
!widget.viewModel.askPrice;
});
},
),
),
],
),
),
),
SizedBox(height: 100),
SvgPicture.asset('assets/scan-overlay.svg'),
],
),
),
],
),
),
SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Center(
child: TextButton(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
theme.cardColor,
),
),
onPressed: () => _formDialogBuilder(
context,
controller,
widget.viewModel,
),
child: Text("Enregistrer manuellement"),
),
),
],
),
),
],
),
],
},
),
);
// },

View file

@ -141,11 +141,14 @@ class _OwnerPopupState extends State<OwnerPopup> {
),
SizedBox(height: 10),
ElevatedButton(
onPressed: () {
onPressed: () async {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
widget.viewModel.currentOwner = widget.viewModel
.addOwner(firstName!, lastName!, contact!);
await widget.viewModel.addOwner(
firstName!,
lastName!,
contact!,
);
setState(() {
showNewOwner = false;
});

View file

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:seshat/data/repositories/auth_repository.dart';
import 'package:seshat/utils/command.dart';
import 'package:seshat/utils/result.dart';
class LoginViewModel extends ChangeNotifier {
LoginViewModel({required AuthRepository authRepository})
: _authRepository = authRepository {
login = Command1<void, (String username, String password)>(_login);
}
final AuthRepository _authRepository;
late Command1 login;
Future<Result<void>> _login((String, String) credentials) async {
final (username, password) = credentials;
final result = await _authRepository.login(username, password);
if (result is Error<void>) {
debugPrint("Hehe no");
}
return result;
}
}

View file

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:seshat/routing/routes.dart';
import 'package:seshat/ui/auth/viewmodel/login_view_model.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key, required this.viewModel});
final LoginViewModel viewModel;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final TextEditingController _username = TextEditingController(
text: "ueauvergne",
);
final TextEditingController _password = TextEditingController(
text: "ueauvergne",
);
@override
void initState() {
super.initState();
widget.viewModel.login.addListener(_onResult);
}
@override
void didUpdateWidget(covariant LoginPage oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.viewModel.removeListener(_onResult);
widget.viewModel.login.addListener(_onResult);
}
@override
void dispose() {
widget.viewModel.login.removeListener(_onResult);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextField(controller: _username),
TextField(controller: _password),
ListenableBuilder(
listenable: widget.viewModel.login,
builder: (context, child) {
return FilledButton(
onPressed: () {
widget.viewModel.login.execute((
_username.value.text,
_password.value.text,
));
},
child: Text("Connexion"),
);
},
),
],
),
);
}
void _onResult() {
if (widget.viewModel.login.completed) {
widget.viewModel.login.clearResult();
context.go(Routes.add);
}
if (widget.viewModel.login.error) {
widget.viewModel.login.clearResult();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Une erreur est survenue lors de la connexion."),
action: SnackBarAction(
label: "Réessayer",
onPressed: () => widget.viewModel.login.execute((
_username.value.text,
_password.value.text,
)),
),
),
);
}
}
}