diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 3555aa2..7008872 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -15,4 +15,4 @@ jobs: cache: true - run: git config --global --add safe.directory /opt/hostedtoolcache/flutter/stable-3.35.1-x64 - run: flutter build web --release --wasm --base-href /app/ - - run: sshpass -p "${{ secrets.DEPLOY_PASSWORD }}" scp -o StrictHostKeyChecking=accept-new -rp build/web ${{ secrets.DEPLOY_USERNAME }}@${{ secrets.DEPLOY_ADDRESS }}:${{ secrets.DEPLOY_PATH }} + - run: sshpass -p "${{ secrets.DEPLOY_PASSWORD }}" scp -o StrictHostKeyChecking=accept-new -rp build/web/* ${{ secrets.DEPLOY_USERNAME }}@${{ secrets.DEPLOY_ADDRESS }}:${{ secrets.DEPLOY_PATH }} diff --git a/README.md b/README.md index 304117b..a5a46b7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +> [!WARNING] +> This repo has been moved to [illes](https://git.illes.fr/UEAuvergne/Seshat). + # seshat Client android/iOS/web, écrit en dart x flutter, pour Alexandria. diff --git a/lib/data/repositories/auth_repository.dart b/lib/data/repositories/auth_repository.dart index 2b56ddb..c55d564 100644 --- a/lib/data/repositories/auth_repository.dart +++ b/lib/data/repositories/auth_repository.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:seshat/data/services/auth_client.dart'; import 'package:seshat/utils/result.dart'; +/// Repository to manage authentification class AuthRepository extends ChangeNotifier { AuthRepository({required AuthClient authClient}) : _authClient = authClient; @@ -9,6 +10,7 @@ class AuthRepository extends ChangeNotifier { bool? _isAuthenticated; + /// Checks the validity of the token if not already checked Future get isLoggedIn async { if (_isAuthenticated != null) { return _isAuthenticated!; @@ -25,6 +27,7 @@ class AuthRepository extends ChangeNotifier { } } + /// Logs in the user Future> login(String username, String password) async { try { final result = await _authClient.login(username, password); @@ -33,13 +36,14 @@ class AuthRepository extends ChangeNotifier { _isAuthenticated = true; return Result.ok(()); case Error(): - return Result.error(result.error); + return result; } } catch (e) { return Result.error(Exception(e)); } } + /// Gets the API's remote version Future> getRemoteApiVersion() async { return await _authClient.getRemoteApiVersion(); } diff --git a/lib/data/repositories/bal_repository.dart b/lib/data/repositories/bal_repository.dart index ccce2e4..97bc023 100644 --- a/lib/data/repositories/bal_repository.dart +++ b/lib/data/repositories/bal_repository.dart @@ -5,13 +5,19 @@ import 'package:seshat/domain/models/bal_stats.dart'; import 'package:seshat/domain/models/enums.dart'; import 'package:seshat/utils/result.dart'; +/// Repository to manage [Bal] class BalRepository { BalRepository({required ApiClient apiClient}) : _apiClient = apiClient; final ApiClient _apiClient; - List? _bals; - Accounting? accounting; + /// [List] of all the user's [Bal] + List? _bals; + + /// [Accounting] of [Bal], mapped by [Bal] id + final Map _accountingMap = {}; + + /// Gets a list of all [Bal] from cache or remote Future>> getBals() async { if (_bals != null) { return Result.ok(_bals!); @@ -26,6 +32,7 @@ class BalRepository { } } + /// Gets a list of all [Bal] from remote only Future>> _getBalsNoCache() async { final result = await _apiClient.getBals(); switch (result) { @@ -37,15 +44,16 @@ class BalRepository { } } - Future> balById(int id) async { + /// Gets a [Bal] by [balId], either from cache or remote + Future> balById(int balId) async { if (_bals == null) { await getBals(); } - Bal? bal = _bals!.where((bal) => bal.id == id).firstOrNull; + Bal? bal = _bals!.where((bal) => bal.id == balId).firstOrNull; if (bal != null) { return Result.ok(bal); } - final result = await _apiClient.getBalById(id); + final result = await _apiClient.getBalById(balId); switch (result) { case Ok(): return Result.ok(result.value); @@ -54,11 +62,13 @@ class BalRepository { } } + /// Return wether or not a [Bal] is currently [BalState.ongoing] bool isABalOngoing() { return _bals?.where((bal) => bal.state == BalState.ongoing).isNotEmpty ?? false; } + /// Gets the [Bal] that is [BalState.ongoing] Future ongoingBal() async { if (_bals == null) { await _getBalsNoCache(); @@ -66,12 +76,14 @@ class BalRepository { return _bals!.where((bal) => bal.state == BalState.ongoing).firstOrNull; } + /// Stops a [Bal] and refresh cache Future> stopBal(int id) async { final result = await _apiClient.stopBal(id); _getBalsNoCache(); return result; } + /// Starts a [Bal] and refresh cache Future> startBal(int id) async { if (isABalOngoing()) { return Result.error( @@ -83,52 +95,62 @@ class BalRepository { return result; } + /// Changes a [Bal]'s [name], [startTime] or [endTime] Future> editBal( int id, String name, - DateTime start, - DateTime end, + DateTime startTime, + DateTime endTime, ) async { - final result = await _apiClient.editBal(id, name, start, end); + final result = await _apiClient.editBal(id, name, startTime, endTime); await _getBalsNoCache(); return result; } - Future> addBal(String name, DateTime start, DateTime end) async { - final result = await _apiClient.addBal(name, start, end); + /// Creates a [Bal] from its [name], [startTime] and [endTime] + Future> addBal( + String name, + DateTime startTime, + DateTime endTime, + ) async { + final result = await _apiClient.addBal(name, startTime, endTime); await _getBalsNoCache(); return result; } - Future> getBalStats(int id) async { - return _apiClient.getBalStats(id); + /// Gets a [BalStats] from its [balId] + Future> getBalStats(int balId) async { + return _apiClient.getBalStats(balId); } + /// Get [Accounting] of a [Bal] from remote only Future> getAccountingNoCache(int balId) async { final result = await _apiClient.getAccounting(balId); switch (result) { case Ok(): - accounting = result.value; + _accountingMap[balId] = result.value; break; default: } return result; } + /// Get [Accounting] of a [Bal] from cache or remote Future> getAccounting(int balId) async { - if (accounting != null) { - return Result.ok(accounting!); + if (_accountingMap[balId] != null) { + return Result.ok(_accountingMap[balId]!); } final result = await _apiClient.getAccounting(balId); switch (result) { case Ok(): - accounting = result.value; + _accountingMap[balId] = result.value; break; default: } return result; } + /// Manages what returning (of type [ReturnType]) does to cache and notifies remote Future> returnToId( int balId, int ownerId, @@ -139,26 +161,32 @@ class BalRepository { case Ok(): switch (type) { case ReturnType.books: - final owner = accounting?.owners + final owner = _accountingMap[balId]?.owners .where((el) => el.ownerId == ownerId) .firstOrNull; if (owner?.owedMoney == 0) { - accounting?.owners.removeWhere((el) => el.ownerId == ownerId); + _accountingMap[balId]?.owners.removeWhere( + (el) => el.ownerId == ownerId, + ); } owner?.owed = []; owner?.owedInstances = []; break; case ReturnType.money: - final owner = accounting?.owners + final owner = _accountingMap[balId]?.owners .where((el) => el.ownerId == ownerId) .firstOrNull; if (owner?.owed == null || owner!.owed.isEmpty) { - accounting?.owners.removeWhere((el) => el.ownerId == ownerId); + _accountingMap[balId]?.owners.removeWhere( + (el) => el.ownerId == ownerId, + ); } owner?.owedMoney = 0; break; case ReturnType.all: - accounting?.owners.removeWhere((el) => el.ownerId == ownerId); + _accountingMap[balId]?.owners.removeWhere( + (el) => el.ownerId == ownerId, + ); break; } break; diff --git a/lib/data/repositories/book_instance_repository.dart b/lib/data/repositories/book_instance_repository.dart index 4a07167..7a60d19 100644 --- a/lib/data/repositories/book_instance_repository.dart +++ b/lib/data/repositories/book_instance_repository.dart @@ -6,16 +6,19 @@ import 'package:seshat/domain/models/owner.dart'; import 'package:seshat/domain/models/search_result.dart'; import 'package:seshat/utils/result.dart'; +/// Repository to manage [BookInstance] class BookInstanceRepository { BookInstanceRepository({required ApiClient apiClient}) : _apiClient = apiClient; final ApiClient _apiClient; + /// Gets a [List] from an [ean] Future>> getByEan(int balId, int ean) async { return await _apiClient.getBookInstanceByEAN(balId, ean); } + /// Gets a [List] from a [title] and [author] Future>> getBySearch( int balId, String title, @@ -24,15 +27,17 @@ class BookInstanceRepository { return await _apiClient.getBookInstanceBySearch(balId, title, author); } - Future> sendBook( + /// Sends a new [BookInstance]'s [book], [owner], [bal] and [price] + Future> sendNewBookInstance( Book book, Owner owner, Bal bal, double price, ) async { - return await _apiClient.sendBook(book, owner, bal, price); + return await _apiClient.sendNewBookInstance(book, owner, bal, price); } + /// Sells a [List] Future> sellBooks(List books) async { Map res = {}; for (BookInstance instance in books) { diff --git a/lib/data/repositories/book_repository.dart b/lib/data/repositories/book_repository.dart index 984d85b..5d38be1 100644 --- a/lib/data/repositories/book_repository.dart +++ b/lib/data/repositories/book_repository.dart @@ -2,16 +2,19 @@ import 'package:seshat/data/services/api_client.dart'; import 'package:seshat/domain/models/book.dart'; import 'package:seshat/utils/result.dart'; +/// Repository to manage [Book] class BookRepository { BookRepository({required ApiClient apiClient}) : _apiClient = apiClient; final ApiClient _apiClient; + /// Gets a [Book] by its [ean] Future> getBookByEAN(String ean) async { return await _apiClient.getBookByEAN(ean); } - Future> getBookById(int id) async { - return await _apiClient.getBookById(id); + /// Gets a [Book] by its [bookId] + Future> getBookById(int bookId) async { + return await _apiClient.getBookById(bookId); } } diff --git a/lib/data/repositories/owner_repository.dart b/lib/data/repositories/owner_repository.dart index 5020123..817bea0 100644 --- a/lib/data/repositories/owner_repository.dart +++ b/lib/data/repositories/owner_repository.dart @@ -5,6 +5,7 @@ import 'package:seshat/data/services/websocket_client.dart'; import 'package:seshat/domain/models/owner.dart'; import 'package:seshat/utils/result.dart'; +/// Repository to manage [Owner] class OwnerRepository { OwnerRepository({ required ApiClient apiClient, @@ -14,18 +15,25 @@ class OwnerRepository { final ApiClient _apiClient; final WebsocketClient _wsClient; - late final StreamSubscription sub; - List? _cachedOwners; - Owner? _sectionOwner; - Future> get sectionOwner async { - if (_sectionOwner != null) { - return Result.ok(_sectionOwner!); + /// [StreamSubscription] to the [Stream] for [_wsClient] + late final StreamSubscription sub; + + /// [List] of owners, updated by [_wsClient] + List? _cachedOwners; + + /// [Owner] of the current user + Owner? _ownerOfUser; + + /// [Owner] of the current user + Future> get ownerOfUser async { + if (_ownerOfUser != null) { + return Result.ok(_ownerOfUser!); } - final result = await _apiClient.getSectionOwner(); + final result = await _apiClient.getOwnerOfUser(); switch (result) { case Ok(): - _sectionOwner = result.value; + _ownerOfUser = result.value; break; default: break; @@ -33,16 +41,17 @@ class OwnerRepository { return result; } - Future> getOwnerById(int id) async { + /// Gets an [Owner] from its [ownerId] + Future> getOwnerById(int ownerId) async { if (_cachedOwners != null) { final result1 = _cachedOwners! - .where((owner) => owner.id == id) + .where((owner) => owner.id == ownerId) .firstOrNull; if (result1 != null) { return Result.ok(result1); } } - return await _apiClient.getOwnerById(id); + return await _apiClient.getOwnerById(ownerId); } /// Adds an [Owner] to the database, and gets the resulting [Owner]. diff --git a/lib/data/services/api_client.dart b/lib/data/services/api_client.dart index 2d9e273..c638d4c 100644 --- a/lib/data/services/api_client.dart +++ b/lib/data/services/api_client.dart @@ -1,7 +1,5 @@ import 'dart:convert'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart'; import 'package:logger/logger.dart'; @@ -11,9 +9,9 @@ import 'package:seshat/domain/models/bal.dart'; import 'package:seshat/domain/models/bal_stats.dart'; import 'package:seshat/domain/models/book.dart'; import 'package:seshat/domain/models/book_instance.dart'; +import 'package:seshat/domain/models/enums.dart'; import 'package:seshat/domain/models/owner.dart'; import 'package:seshat/domain/models/search_result.dart'; -import 'package:seshat/utils/command.dart'; import 'package:seshat/utils/result.dart'; extension StringExtension on String { @@ -22,12 +20,17 @@ extension StringExtension on String { } } +/// API Client to manage all authenticated REST routes class ApiClient { - ApiClient({String? host, int? port}); + ApiClient(); - late final Command0 load; + /// JWT for registration String? token; + + /// Readiness of the API Client bool isReady = false; + + /// Storage to access JWT FlutterSecureStorage? _secureStorage; Logger log = Logger( printer: PrettyPrinter( @@ -38,10 +41,12 @@ class ApiClient { ), ); + /// Initializes connection to the [_secureStorage] Future _initStore() async { - _secureStorage ??= const FlutterSecureStorage(aOptions: AndroidOptions()); + _secureStorage ??= const FlutterSecureStorage(); } + /// Generates authorization headers and option [additionalHeaders] Future> _getHeaders([ Map? additionalHeaders, ]) async { @@ -57,6 +62,7 @@ class ApiClient { * ======================== */ + /// Gets data about a BAL's needed returns Future> getAccounting(int balId) async { final url = "https://$apiBasePath/bal/$balId/accounting"; log.i("Fetching: getAccounting ($url)"); @@ -83,6 +89,8 @@ class ApiClient { } } + /// Notifies the server that either Books, Money or All has been returned to the user + /// [type] is the stringified version of [ReturnType] Future> returnToId(int balId, int ownerId, String type) async { final url = "https://$apiBasePath/bal/${balId.toString()}/accounting/return/${ownerId.toString()}"; @@ -122,8 +130,9 @@ class ApiClient { * ================= */ - Future> getBalStats(int id) async { - final url = "https://$apiBasePath/bal/${id.toString()}/stats"; + /// Get stats about a BAL's performance + Future> getBalStats(int balId) async { + final url = "https://$apiBasePath/bal/${balId.toString()}/stats"; log.i("Fetching: getBalStats ($url)"); final client = Client(); try { @@ -150,8 +159,9 @@ class ApiClient { } } - Future> stopBal(int id) async { - final url = "https://$apiBasePath/bal/${id.toString()}/stop"; + /// Stops a BAL, putting it's [BalState] to [BalState.ended] + Future> stopBal(int balId) async { + final url = "https://$apiBasePath/bal/${balId.toString()}/stop"; log.i("Fetching: stopBal ($url)"); final client = Client(); try { @@ -178,8 +188,9 @@ class ApiClient { } } - Future> startBal(int id) async { - final url = "https://$apiBasePath/bal/${id.toString()}/start"; + /// Starts a BAL, putting it's [BalState] to [BalState.ongoing] + Future> startBal(int balId) async { + final url = "https://$apiBasePath/bal/${balId.toString()}/start"; log.i("Fetching: startBal ($url)"); final client = Client(); try { @@ -206,21 +217,22 @@ class ApiClient { } } + /// Changes the information about a [Bal], such as its [name], [startTime] or [endTime] Future> editBal( - int id, + int balId, String name, - DateTime start, - DateTime end, + DateTime startTime, + DateTime endTime, ) async { - final url = "https://$apiBasePath/bal/${id.toString()}"; + final url = "https://$apiBasePath/bal/${balId.toString()}"; log.i("Fetching: editBal ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); final body = { "name": name, - "start_timestamp": (start.millisecondsSinceEpoch / 1000).round(), - "end_timestamp": (end.millisecondsSinceEpoch / 1000).round(), + "start_timestamp": (startTime.millisecondsSinceEpoch / 1000).round(), + "end_timestamp": (endTime.millisecondsSinceEpoch / 1000).round(), }; final response = await client.patch( Uri.parse(url), @@ -246,8 +258,9 @@ class ApiClient { } } - Future> getBalById(int id) async { - final url = "https://$apiBasePath/bal/${id.toString()}"; + /// Gets a [Bal] from it's [balId] + Future> getBalById(int balId) async { + final url = "https://$apiBasePath/bal/${balId.toString()}"; log.i("Fetching: getBalById ($url)"); final client = Client(); try { @@ -272,7 +285,12 @@ class ApiClient { } } - Future> addBal(String name, DateTime start, DateTime end) async { + /// Adds a [Bal] from it's [name], [startTime] and [endTime] + Future> addBal( + String name, + DateTime startTime, + DateTime endTime, + ) async { final url = "https://$apiBasePath/bal"; log.i("Fetching: addBal ($url)"); final client = Client(); @@ -280,8 +298,8 @@ class ApiClient { final headers = await _getHeaders({"Content-Type": "application/json"}); final body = { "name": name, - "start_timestamp": (start.millisecondsSinceEpoch / 1000).round(), - "end_timestamp": (end.millisecondsSinceEpoch / 1000).round(), + "start_timestamp": (startTime.millisecondsSinceEpoch / 1000).round(), + "end_timestamp": (endTime.millisecondsSinceEpoch / 1000).round(), }; final response = await client.post( Uri.parse(url), @@ -305,6 +323,7 @@ class ApiClient { } } + /// Gets a [List] of all [Bal] Future>> getBals() async { final url = "https://$apiBasePath/bals"; log.i("Fetching: getBals ($url)"); @@ -312,28 +331,32 @@ class ApiClient { try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); - if (response.statusCode == 200) { - final json = jsonDecode(response.body) as List; - return Result.ok(json.map((element) => Bal.fromJSON(element)).toList()); - } else { - throw Exception("Something wrong happened"); + + switch (response.statusCode) { + case 200: + final json = jsonDecode(response.body) as List; + return Result.ok( + json.map((element) => Bal.fromJSON(element)).toList(), + ); + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { - debugPrint("ERROR: ${e.toString()}"); + log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } - Future> getCurrentBal() async { + /// Gets the ongoing BAL for the user + Future> getOngoingBal() async { + final url = "https://$apiBasePath/bal/current"; + log.i("Fetching: getCurrentBal ($url)"); final client = Client(); try { final headers = await _getHeaders(); - final response = await client.get( - Uri.parse("https://$apiBasePath/bal/current"), - headers: headers, - ); + final response = await client.get(Uri.parse(url), headers: headers); if (response.statusCode == 200) { final json = jsonDecode(response.body); return Result.ok(Bal.fromJSON(json)); @@ -355,42 +378,50 @@ class ApiClient { * =================== */ - Future> getBookById(int id) async { + /// Gets a [Book] by its [bookId] + Future> getBookById(int bookId) async { + final url = "https://$apiBasePath/book/id/${bookId.toString()}"; + log.i("Fetching: getBookById ($url)"); final client = Client(); try { final headers = await _getHeaders(); - final response = await client.get( - Uri.parse("https://$apiBasePath/book/id/${id.toString()}"), - headers: headers, - ); - if (response.statusCode == 200) { - final json = jsonDecode(response.body); - return Result.ok(Book.fromJSON(json)); - } else { - throw Exception("The book was not found"); + final response = await client.get(Uri.parse(url), headers: headers); + switch (response.statusCode) { + case 200: + final json = jsonDecode(response.body); + return Result.ok(Book.fromJSON(json)); + case 404: + throw "No book with this id exists in database"; + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { + log.e(e.toString()); return Result.error(Exception("API $e")); } finally { client.close(); } } + /// Gets a [Book] from its [ean] Future> getBookByEAN(String ean) async { + final url = "https://$apiBasePath/book/ean/$ean"; + log.i("Fetching: getBookByEan ($url)"); final client = Client(); try { final headers = await _getHeaders(); - final response = await client.get( - Uri.parse("https://$apiBasePath/book/ean/$ean"), - headers: headers, - ); - if (response.statusCode == 200) { - final json = jsonDecode(response.body); - return Result.ok(Book.fromJSON(json)); - } else { - throw Exception("The book was not found"); + final response = await client.get(Uri.parse(url), headers: headers); + switch (response.statusCode) { + case 200: + final json = jsonDecode(response.body); + return Result.ok(Book.fromJSON(json)); + case 404: + throw "No book with this EAN found in the database of BNF"; + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { + log.e(e.toString()); return Result.error(Exception("API $e")); } finally { client.close(); @@ -403,93 +434,118 @@ class ApiClient { * ============================= */ + /// Gets a [BookInstance] from it's [title], [author] or both Future>> getBookInstanceBySearch( int balId, String title, String author, ) async { + final url = "https://$apiBasePath/bal/${balId.toString()}/search"; + log.i("Fetching: getBookInstancesBySearch ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); final body = jsonEncode({"title": title, "author": author}); - debugPrint("\n\n\n\n$body\n\n\n\n"); final response = await client.post( - Uri.parse("https://$apiBasePath/bal/${balId.toString()}/search"), + Uri.parse(url), headers: headers, body: body, ); - if (response.statusCode == 200) { - final json = jsonDecode(response.body) as List; - debugPrint("\n\n\n\nJSON : $json\n\n\n\n"); - return Result.ok(json.map((el) => SearchResult.fromJSON(el)).toList()); - } else { - throw "Unknown Error"; + switch (response.statusCode) { + case 200: + final json = jsonDecode(response.body) as List; + return Result.ok( + json.map((el) => SearchResult.fromJSON(el)).toList(), + ); + case 403: + throw "You do not own the BAL"; + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { - debugPrint("\n\n\n\nERROR: ${e.toString()}\n\n\n\n"); + log.e(e.toString()); return Result.error(Exception("API $e")); } finally { client.close(); } } + /// Gets a [BookInstance] from it's [ean] Future>> getBookInstanceByEAN( int balId, int ean, ) async { + final url = + "https://$apiBasePath/bal/${balId.toString()}/ean/${ean.toString()}/book_instances"; + log.i("Fetching: getBookInstancesByEan ($url)"); final client = Client(); try { final headers = await _getHeaders(); - final response = await client.get( - Uri.parse( - "https://$apiBasePath/bal/${balId.toString()}/ean/${ean.toString()}/book_instances", - ), - headers: headers, - ); - if (response.statusCode == 200) { - final json = jsonDecode(response.body) as List; - return Result.ok(json.map((el) => BookInstance.fromJSON(el)).toList()); - } else { - throw "Unknown Error"; + final response = await client.get(Uri.parse(url), headers: headers); + switch (response.statusCode) { + case 200: + final json = jsonDecode(response.body) as List; + return Result.ok( + json.map((el) => BookInstance.fromJSON(el)).toList(), + ); + case 403: + throw "You do not own the BAL"; + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { + log.e(e.toString()); return Result.error(Exception("API $e")); } finally { client.close(); } } + /// Notifies the server of the sell of multiple [BookInstance]. [books] is in the form of + /// ```dart + /// final books = {"aBookInstanceId": 6.0} // and its price + /// ``` Future> sellBooks(Map books) async { + final url = "https://$apiBasePath/book_instance/sell/bulk"; + log.i("Fetching: sellBooks ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); - debugPrint("\n\n\n\nMAP: $books\n\n\n\n"); final body = jsonEncode(books); - debugPrint("\n\n\n\nSENT: $body\n\n\n\n"); final response = await client.post( - Uri.parse("https://$apiBasePath/book_instance/sell/bulk"), + Uri.parse(url), headers: headers, body: body, ); - if (response.statusCode == 200) { - return Result.ok(response.statusCode); - } else { - throw "Unknown error"; + switch (response.statusCode) { + case 200: + return Result.ok(response.statusCode); + case 403: + throw "You don't own one of the specified books"; + case 404: + throw "One of the books was not found"; + case 409: + throw "One of the books wasn't available"; + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { - debugPrint("\n\n\n\nERROR : ${e.toString()}\n\n\n\n"); + log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } - Future> sendBook( + /// Creates a new [BookInstance] from it's [book], it's [owner], it's [bal] and it's [price] + Future> sendNewBookInstance( Book book, Owner owner, Bal bal, double price, ) async { + final url = "https://$apiBasePath/book_instance"; + log.i("Fetching: sendBook ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); @@ -500,17 +556,18 @@ class ApiClient { "price": price, }); final response = await client.post( - Uri.parse("https://$apiBasePath/book_instance"), + Uri.parse(url), headers: headers, body: body, ); - if (response.statusCode == 201) { - final json = jsonDecode(response.body); - return Result.ok(BookInstance.fromJSON(json)); - } else if (response.statusCode == 403) { - throw Exception("You don't own that book instance"); - } else { - throw Exception("Something wrong happened"); + switch (response.statusCode) { + case 201: + final json = jsonDecode(response.body); + return Result.ok(BookInstance.fromJSON(json)); + case 403: + throw "You don't own that book instance"; + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { return Result.error(Exception(e)); @@ -525,64 +582,72 @@ class ApiClient { * ==================== */ - Future> getOwnerById(int id) async { + /// Gets an [Owner] by it's [ownerId] + Future> getOwnerById(int ownerId) async { + final url = "https://$apiBasePath/owner/${ownerId.toString()}"; + log.i("Fetching: getOwnerById ($url)"); final client = Client(); try { final headers = await _getHeaders(); - final response = await client.get( - Uri.parse("https://$apiBasePath/owner/${id.toString()}"), - headers: headers, - ); - if (response.statusCode == 200) { - final json = jsonDecode(response.body); - return Result.ok(Owner.fromJSON(json)); - } else { - throw Exception("The owner was not found"); + final response = await client.get(Uri.parse(url), headers: headers); + switch (response.statusCode) { + case 200: + final json = jsonDecode(response.body); + return Result.ok(Owner.fromJSON(json)); + case 403: + throw "You do not own specified owner"; + case 404: + throw "No owner with this id exists"; + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { + log.e(e.toString()); return Result.error(Exception("API $e")); } finally { client.close(); } } - Future> getSectionOwner() async { + /// Get the owner of the current user + Future> getOwnerOfUser() async { + final url = "https://$apiBasePath/owner/self"; + log.i("Fetching: getSectionOwner ($url)"); final client = Client(); try { final headers = await _getHeaders(); - final response = await client.get( - Uri.parse("https://$apiBasePath/owner/self"), - headers: headers, - ); - if (response.statusCode == 200) { - final json = jsonDecode(response.body); - return Result.ok(Owner.fromJSON(json)); - } else { - throw "Unknown error"; + final response = await client.get(Uri.parse(url), headers: headers); + switch (response.statusCode) { + case 200: + final json = jsonDecode(response.body); + return Result.ok(Owner.fromJSON(json)); + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { + log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } - /// Call on `/owners` to get a list of all [Owner]s + /// Get a [List] of all [Owner] Future>> getOwners() async { + final url = "https://$apiBasePath/owners"; + log.i("Fetching: getOwners ($url)"); final client = Client(); try { final headers = await _getHeaders(); - final response = await client.get( - Uri.parse("https://$apiBasePath/owners"), - headers: headers, - ); - if (response.statusCode == 200) { - final json = jsonDecode(response.body) as List; - return Result.ok( - json.map((element) => Owner.fromJSON(element)).toList(), - ); - } else { - throw Exception("Invalid request"); + final response = await client.get(Uri.parse(url), headers: headers); + switch (response.statusCode) { + case 200: + final json = jsonDecode(response.body) as List; + return Result.ok( + json.map((element) => Owner.fromJSON(element)).toList(), + ); + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } on Exception catch (error) { return Result.error(error); @@ -591,12 +656,14 @@ class ApiClient { } } - /// Adds an owner to the database + /// Adds an [Owner] from its [firstName], [lastName] and [contact] Future> addOwner( String firstName, String lastName, String contact, ) async { + final url = "https://$apiBasePath/owner"; + log.i("Fetching: addOwner ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); @@ -606,18 +673,20 @@ class ApiClient { "contact": contact, }; final response = await client.post( - Uri.parse("https://$apiBasePath/owner"), + Uri.parse(url), headers: headers, body: jsonEncode(body), ); - if (response.statusCode == 201) { - final json = jsonDecode(response.body); - return Result.ok(Owner.fromJSON(json)); - } else { - throw Exception("Invalid request"); + switch (response.statusCode) { + case 201: + final json = jsonDecode(response.body); + return Result.ok(Owner.fromJSON(json)); + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } - } on Exception catch (error) { - return Result.error(error); + } catch (e) { + log.e(e.toString()); + return Result.error(Exception(e)); } finally { client.close(); } diff --git a/lib/data/services/auth_client.dart b/lib/data/services/auth_client.dart index 4d77b4f..3dc8496 100644 --- a/lib/data/services/auth_client.dart +++ b/lib/data/services/auth_client.dart @@ -1,29 +1,43 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:logger/logger.dart'; import 'package:seshat/config/constants.dart'; import 'package:seshat/utils/result.dart'; -import "package:http/http.dart" as http; +import "package:http/http.dart"; +/// API Client to manage all unauthenticated REST routes class AuthClient { AuthClient(); - FlutterSecureStorage? _secureStorage; + /// Storage to access JWT + FlutterSecureStorage? _secureStorage; + Logger log = Logger( + printer: PrettyPrinter( + colors: true, + lineLength: 100, + methodCount: 0, + dateTimeFormat: DateTimeFormat.dateAndTime, + ), + ); + + /// Initializes connection to the [_secureStorage] Future _initStore() async { - _secureStorage ??= const FlutterSecureStorage( - aOptions: AndroidOptions(encryptedSharedPreferences: true), - ); + _secureStorage ??= const FlutterSecureStorage(); } + /// Verifies the validity of the token stored in [_secureStorage] Future> hasValidToken() async { + final url = "https://$apiBasePath/token-check"; + log.i("Fetching: hasValidToken ($url)"); + final client = Client(); try { await _initStore(); bool hasToken = await _secureStorage!.containsKey(key: "token"); if (hasToken) { var token = await _secureStorage!.read(key: "token"); - var url = Uri.parse("https://$apiBasePath/token-check"); - var response = await http.post( - url, + var response = await client.post( + Uri.parse(url), headers: {"Content-Type": "application/json"}, body: jsonEncode({"token": token}), ); @@ -34,52 +48,64 @@ class AuthClient { } return Result.ok(false); } catch (e) { - return Result.error(Exception(e)); - } - } - - Future> 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) { + log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } - Future> getRemoteApiVersion() async { - final client = http.Client(); + /// Logs a user in from its [username] and [password] + Future> login(String username, String password) async { + final url = "https://$apiBasePath/auth"; + log.i("Logging in: $url"); + var client = Client(); try { - final response = await client.get( - Uri.parse("https://$apiBasePath/version"), + await _initStore(); + var response = await client.post( + Uri.parse(url), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"password": password, "username": username}), ); - if (response.statusCode == 200) { - final json = jsonDecode(response.body) as int; - return Result.ok(json); - } else { - throw "Something wrong happened"; + switch (response.statusCode) { + case 200: + var json = jsonDecode(response.body); + await _secureStorage!.write( + key: "token", + value: json["access_token"], + ); + return Result.ok(json["access_token"]); + case 401: + throw "Wrong credentials"; + case 500: + throw "Token creation error"; + default: + throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { + log.e(e.toString()); + return Result.error(Exception(e)); + } finally { + client.close(); + } + } + + /// Gets the API version of the server + Future> getRemoteApiVersion() async { + final url = "https://$apiBasePath/version"; + log.i("Fetching: getRemoteApiVersion ($url)"); + final client = Client(); + try { + final response = await client.get(Uri.parse(url)); + switch (response.statusCode) { + case 200: + final json = jsonDecode(response.body) as int; + return Result.ok(json); + default: + throw "Unknown error with code ${response.statusCode.toString()}"; + } + } catch (e) { + log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); diff --git a/lib/data/services/websocket_client.dart b/lib/data/services/websocket_client.dart index f8df18a..1d85989 100644 --- a/lib/data/services/websocket_client.dart +++ b/lib/data/services/websocket_client.dart @@ -2,33 +2,55 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:logger/logger.dart'; import 'package:rxdart/rxdart.dart'; import 'package:seshat/config/constants.dart'; import 'package:seshat/domain/models/owner.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +/// API Client to manages connections to WebSockets class WebsocketClient { - WebSocketChannel? _channel; + /// Storage to access JWT FlutterSecureStorage? _secureStorage; + + /// Raw channel of data from WebSocket + WebSocketChannel? _channel; + + /// Global WebSocket Stream final BehaviorSubject _baseController = BehaviorSubject(); + + /// WebSocket Stream dedicated to [Owner] entries final BehaviorSubject _ownersController = BehaviorSubject( sync: true, ); - late final StreamSubscription sub; + /// Subscription to [_baseController] + late final StreamSubscription sub; + Logger log = Logger( + printer: PrettyPrinter( + colors: true, + lineLength: 100, + methodCount: 0, + dateTimeFormat: DateTimeFormat.dateAndTime, + ), + ); + + /// Gets a stream of [Owner] Stream get owners => _ownersController.stream; + /// Initializes connection to the [_secureStorage] Future _initStore() async { - _secureStorage ??= const FlutterSecureStorage( - aOptions: AndroidOptions(encryptedSharedPreferences: true), - ); + _secureStorage ??= const FlutterSecureStorage(); } + /// Connects to the websocket Future connect() async { + final url = "wss://$apiBasePath/ws"; + log.i("Webocket: $url"); await _initStore(); if (_channel != null) return; - _channel = WebSocketChannel.connect(Uri.parse("wss://$apiBasePath/ws")); + _channel = WebSocketChannel.connect(Uri.parse(url)); await _channel!.ready; var token = await _secureStorage!.read(key: "token"); @@ -60,11 +82,13 @@ class WebsocketClient { } } + /// Disconnects from the websocket void _handleDisconnect() { sub.cancel(); _channel = null; } + /// Closes all connections void dispose() { sub.cancel(); _channel?.sink.close(); diff --git a/lib/routing/router.dart b/lib/routing/router.dart index c330f51..fd530ec 100644 --- a/lib/routing/router.dart +++ b/lib/routing/router.dart @@ -55,7 +55,7 @@ GoRouter router(AuthRepository authRepository) => GoRouter( pageBuilder: (context, state) { final viewModel = BalViewModel( balRepository: context.read(), - id: int.parse(state.pathParameters["id"] ?? ""), + selectedBalId: int.parse(state.pathParameters["id"] ?? ""), ownerRepository: context.read(), ); return NoTransitionPage(child: BalPage(viewModel: viewModel)); @@ -72,11 +72,6 @@ GoRouter router(AuthRepository authRepository) => GoRouter( ); return NoTransitionPage(child: AddPage(viewModel: viewModel)); }, - // routes: [ - // GoRoute(path: Routes.addForm), - // GoRoute(path: Routes.addOwner), - // GoRoute(path: Routes.addPrice), - // ], ), GoRoute( path: Routes.sell, diff --git a/lib/ui/add_page/view_model/add_view_model.dart b/lib/ui/add_page/view_model/add_view_model.dart index 0e685ba..3b0be3a 100644 --- a/lib/ui/add_page/view_model/add_view_model.dart +++ b/lib/ui/add_page/view_model/add_view_model.dart @@ -38,18 +38,29 @@ class AddViewModel extends ChangeNotifier { * ==================== */ + /// Owner currently selected in the ui Owner? _currentOwner; + + /// Owner currently selected in the ui Owner? get currentOwner => _currentOwner; set currentOwner(Owner? owner) { _currentOwner = owner; notifyListeners(); } - Owner? _sectionOwner; - Owner? get sectionOwner => _sectionOwner; + /// Owner of the current user + Owner? _ownerOfUser; + /// Owner of the current user + Owner? get ownerOfUser => _ownerOfUser; + + /// All the [Owner] List _owners = []; + + /// All the [Owner] List? get owners => _owners; + + /// Adds an owner from it's [firstName], [lastName] and [contact] Future> addOwner( String firstName, String lastName, @@ -85,8 +96,11 @@ class AddViewModel extends ChangeNotifier { * ================= */ - Bal? _currentBal; - Bal? get currentBal => _currentBal; + /// Ongoing [Bal] + Bal? _ongoingBal; + + /// Ongoing [Bal] + Bal? get ongoingBal => _ongoingBal; /* * =================== @@ -94,7 +108,10 @@ class AddViewModel extends ChangeNotifier { * =================== */ + /// Wether to ask for a price bool _askPrice = true; + + /// Wether to ask for a price bool get askPrice => _askPrice; set askPrice(bool newValue) { _askPrice = newValue; @@ -107,21 +124,25 @@ class AddViewModel extends ChangeNotifier { * ================================= */ - /// Sends an api request with a [bacorde], then gets the [Book] that was - /// either created or retrieved. Sens the [Book] back wrapped in a [Result]. - Future> scanBook(BarcodeCapture barcode) async { - var ean = barcode.barcodes.first.rawValue!; + /// Retrieves the book associated with an ean through a [barcode] + Future> scanBook(String ean) async { var result = await _bookRepository.getBookByEAN(ean); return result; } - Future> sendBook( + /// Creates a new Book Instance from its [book], [owner], [bal] and [price] + Future> sendNewBookInstance( Book book, Owner owner, Bal bal, double price, ) async { - return await _bookInstanceRepository.sendBook(book, owner, bal, price); + return await _bookInstanceRepository.sendNewBookInstance( + book, + owner, + bal, + price, + ); } /* @@ -130,9 +151,11 @@ class AddViewModel extends ChangeNotifier { * ================================= */ + /// Command to load the view model late final Command0 load; bool isLoaded = false; + /// Manages the loaders Future> _load() async { final result1 = await _loadOwners(); switch (result1) { @@ -153,11 +176,12 @@ class AddViewModel extends ChangeNotifier { return result2; } + /// Loads all necessary data about [Bal]s Future> _loadBal() async { final result = await _balRepository.getBals(); switch (result) { case Ok(): - _currentBal = result.value + _ongoingBal = result.value .where((bal) => bal.state == BalState.ongoing) .firstOrNull; break; @@ -168,6 +192,7 @@ class AddViewModel extends ChangeNotifier { return result; } + /// Loads all the necessary data about [Owner]s Future> _loadOwners() async { final result = await _ownerRepository.getOwners(); switch (result) { @@ -182,10 +207,10 @@ class AddViewModel extends ChangeNotifier { return result; } - final result2 = await _ownerRepository.sectionOwner; + final result2 = await _ownerRepository.ownerOfUser; switch (result2) { case Ok(): - _sectionOwner = result2.value; + _ownerOfUser = result2.value; break; default: } diff --git a/lib/ui/add_page/widgets/add_page.dart b/lib/ui/add_page/widgets/add_page.dart index d66e827..e0e68ec 100644 --- a/lib/ui/add_page/widgets/add_page.dart +++ b/lib/ui/add_page/widgets/add_page.dart @@ -22,30 +22,27 @@ class AddPage extends StatefulWidget { } class _AddPageState extends State { - num? price; - final MobileScannerController controller = MobileScannerController( + final MobileScannerController scannerController = MobileScannerController( formats: [BarcodeFormat.ean13], detectionTimeoutMs: 1000, ); @override void dispose() { - controller.dispose(); + scannerController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); - // return Consumer( - // builder: (context, screen, child) { return Scaffold( bottomNavigationBar: AppNavigationBar(startIndex: 1), body: ListenableBuilder( listenable: widget.viewModel, builder: (context, child) => switch (widget.viewModel.isLoaded) { false => AwaitLoading(), - true => switch (widget.viewModel.currentBal) { + true => switch (widget.viewModel.ongoingBal) { null => Center( child: SizedBox( width: 300, @@ -78,60 +75,37 @@ class _AddPageState extends State { children: [ ColoredBox(color: Colors.black), MobileScanner( - controller: controller, + controller: scannerController, onDetect: (barcodes) async { if (barcodes.barcodes.isEmpty) { return; } + if (widget.viewModel.currentOwner == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Attention : vous devez choisir un·e propriétaire", - ), - behavior: SnackBarBehavior.floating, - ), + _showMissingOwnerSnackBar( + context, + scannerController, + widget.viewModel, ); return; } - void setPrice(num newPrice) async { - setState(() { - price = newPrice; - }); - } - - Result result = await widget.viewModel.scanBook( - barcodes, + _scanEan( + context, + widget.viewModel, + barcodes.barcodes.first.rawValue!, + scannerController, ); - - 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; - } }, ), + Center( child: SvgPicture.asset( 'assets/scan-overlay.svg', height: (MediaQuery.sizeOf(context).height / 5) * 2, ), ), + SafeArea( child: SingleChildScrollView( child: Column( @@ -155,7 +129,7 @@ class _AddPageState extends State { ), onPressed: () => _ownerDialogBuilder( context, - controller, + scannerController, widget.viewModel, ), ), @@ -184,6 +158,7 @@ class _AddPageState extends State { ), ), ), + SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.end, @@ -197,19 +172,17 @@ class _AddPageState extends State { ), onPressed: () { if (widget.viewModel.currentOwner == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Attention : vous devez choisir un·e propriétaire", - ), - behavior: SnackBarBehavior.floating, - ), + _showMissingOwnerSnackBar( + context, + scannerController, + widget.viewModel, ); + return; } _formDialogBuilder( context, - controller, + scannerController, widget.viewModel, ); }, @@ -231,18 +204,71 @@ class _AddPageState extends State { } } +void _scanEan( + BuildContext context, + AddViewModel viewModel, + String ean, + MobileScannerController scannerController, { + Function(BuildContext)? leaveLastPopup, +}) async { + Result result = await viewModel.scanBook(ean); + + if (context.mounted) { + if (leaveLastPopup != null) { + leaveLastPopup(context); + } + switch (result) { + case Ok(): + await _confirmationDialogBuilder( + context, + scannerController, + viewModel, + result.value, + ); + break; + case Error(): + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Erreur : ${result.error}"), + behavior: SnackBarBehavior.floating, + ), + ); + break; + } + } +} + +void _showMissingOwnerSnackBar( + BuildContext context, + MobileScannerController scannerController, + AddViewModel viewModel, +) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Attention : vous devez choisir un·e propriétaire"), + duration: Duration(seconds: 4), + action: SnackBarAction( + label: "Choisir", + onPressed: () => + _ownerDialogBuilder(context, scannerController, viewModel), + ), + behavior: SnackBarBehavior.floating, + ), + ); +} + Future _confirmationDialogBuilder( BuildContext context, - Function(num) setPrice, - MobileScannerController controller, + MobileScannerController scannerController, AddViewModel viewModel, Book book, ) { - controller.stop(); + scannerController.stop(); + // Utility function to pass to downwards widgets void exitPopup(BuildContext localContext) { Navigator.of(localContext).pop(); - controller.start(); + scannerController.start(); } return showDialog( @@ -250,7 +276,6 @@ Future _confirmationDialogBuilder( barrierDismissible: false, builder: (context) => ConfirmationPopup( exitPopup: exitPopup, - setPrice: setPrice, viewModel: viewModel, book: book, ), @@ -264,6 +289,7 @@ Future _formDialogBuilder( ) { controller.stop(); + // Utility function to pass to downwards widgets void exitPopup(BuildContext localContext) { Navigator.of(localContext).pop(); controller.start(); @@ -272,7 +298,12 @@ Future _formDialogBuilder( return showDialog( context: context, barrierDismissible: false, - builder: (context) => FormPopup(viewModel: viewModel, exitPopup: exitPopup), + builder: (context) => FormPopup( + viewModel: viewModel, + exitPopup: exitPopup, + scannerController: controller, + scanEan: _scanEan, + ), ); } @@ -283,7 +314,8 @@ Future _ownerDialogBuilder( ) { controller.stop(); - void onPressAccept(BuildContext localContext) { + // Utility function to pass to downwards widgets + void exitPopup(BuildContext localContext) { Navigator.of(localContext).pop(); controller.start(); } @@ -292,6 +324,6 @@ Future _ownerDialogBuilder( context: context, barrierDismissible: false, builder: (context) => - OwnerPopup(viewModel: viewModel, onPressAccept: onPressAccept), + OwnerPopup(viewModel: viewModel, exitPopup: exitPopup), ); } diff --git a/lib/ui/add_page/widgets/confirmation_popup.dart b/lib/ui/add_page/widgets/confirmation_popup.dart index 980ddee..998e3ea 100644 --- a/lib/ui/add_page/widgets/confirmation_popup.dart +++ b/lib/ui/add_page/widgets/confirmation_popup.dart @@ -7,13 +7,11 @@ class ConfirmationPopup extends StatefulWidget { const ConfirmationPopup({ super.key, required this.exitPopup, - required this.setPrice, required this.viewModel, required this.book, }); final Function(BuildContext) exitPopup; - final Function(num) setPrice; final AddViewModel viewModel; final Book book; @@ -24,9 +22,11 @@ class ConfirmationPopup extends StatefulWidget { class _ConfirmationPopupState extends State { final GlobalKey _formKey = GlobalKey(); double price = 0; + @override Widget build(BuildContext context) { final theme = Theme.of(context); + return AlertDialog( title: Text("Prix"), content: Form( @@ -72,6 +72,7 @@ class _ConfirmationPopupState extends State { ], ), ), + SizedBox(height: 10), (widget.viewModel.askPrice) ? TextFormField( @@ -82,19 +83,26 @@ class _ConfirmationPopupState extends State { ), keyboardType: TextInputType.numberWithOptions( decimal: true, + signed: true, ), validator: (value) { if (value == null || value.isEmpty) { return "Indiquez un prix"; - } else if (num.tryParse(value) == null) { + } else if (double.tryParse( + value.replaceAll(",", "."), + ) == + null) { return "Le prix doit être un nombre"; - } else if (num.parse(value) < 0) { + } else if (double.parse(value.replaceAll(",", ".")) < + 0) { return "Le prix doit être positif ou nul"; } return null; }, onSaved: (newValue) { - price = double.parse(newValue!); + price = double.parse( + newValue?.replaceAll(",", ".") ?? "0", + ); }, ) : SizedBox(), @@ -104,6 +112,7 @@ class _ConfirmationPopupState extends State { ), actions: [ TextButton( + child: Text("Annuler"), onPressed: () { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -113,19 +122,21 @@ class _ConfirmationPopupState extends State { ); widget.exitPopup(context); }, - child: Text("Annuler"), ), TextButton( + child: Text("Valider"), onPressed: () async { if (widget.viewModel.askPrice && _formKey.currentState!.validate()) { _formKey.currentState!.save(); + } else { + return; } - var result = await widget.viewModel.sendBook( + var result = await widget.viewModel.sendNewBookInstance( widget.book, widget.viewModel.currentOwner!, - widget.viewModel.currentBal!, + widget.viewModel.ongoingBal!, price, ); @@ -135,49 +146,61 @@ class _ConfirmationPopupState extends State { Navigator.of(context).pop(); showDialog( context: context, - builder: (context) => AlertDialog( - title: Text( - "ID : ${widget.viewModel.currentOwner!.firstName[0].toUpperCase()}${widget.viewModel.currentOwner!.lastName[0].toUpperCase()}${(price == 0) ? "PL" : price.toString()}", - ), - content: Text( - (widget.viewModel.currentOwner!.id == - widget.viewModel.sectionOwner!.id) - ? "Ce livre appartient à la section. Vous pouvez mettre le code, ou poser une gomette, ..." - : "Identifiant propriétaire de ce livre. Pensez à l'écrire pour retrouver lae propriétaire du livre lors de la vente ou du retour !", - ), - actions: [ - TextButton( - onPressed: () { - widget.exitPopup(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Enregistré: ${widget.book.title}", - ), - behavior: SnackBarBehavior.floating, - ), - ); - }, - child: Text("Ok"), - ), - ], - ), + barrierDismissible: false, + builder: (context) => + RegisteredBookPopup(widget: widget, price: price), ); } break; case Error(): if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Une erreur est survenue : ${result.error}", - ), - ), + SnackBar(content: Text("Erreur : ${result.error}")), ); } } }, - child: Text("Valider"), + ), + ], + ); + } +} + +class RegisteredBookPopup extends StatelessWidget { + const RegisteredBookPopup({ + super.key, + required this.widget, + required this.price, + }); + + final ConfirmationPopup widget; + final double price; + + @override + Widget build(BuildContext context) { + return AlertDialog( + // This thing is the BookInstance's short ID + title: Text( + "ID : ${widget.viewModel.currentOwner!.firstName[0].toUpperCase()}${widget.viewModel.currentOwner!.lastName[0].toUpperCase()}${(price == 0) ? "PL" : price.toString()}", + ), + content: Text( + (widget.viewModel.currentOwner!.id == widget.viewModel.ownerOfUser!.id) + ? "Pensez à la gomette ! Ce livre appartient au syndicat." + : "Identifiant propriétaire de ce livre. Pensez à l'écrire pour retrouver lae propriétaire du livre lors de la vente ou du retour !", + ), + actions: [ + TextButton( + child: Text("Ok"), + onPressed: () { + widget.exitPopup(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Livre enregistré: ${widget.book.title}"), + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 2), + ), + ); + }, ), ], ); diff --git a/lib/ui/add_page/widgets/form_popup.dart b/lib/ui/add_page/widgets/form_popup.dart index 59368e0..47a3104 100644 --- a/lib/ui/add_page/widgets/form_popup.dart +++ b/lib/ui/add_page/widgets/form_popup.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:seshat/ui/add_page/view_model/add_view_model.dart'; class FormPopup extends StatelessWidget { @@ -6,10 +7,14 @@ class FormPopup extends StatelessWidget { super.key, required this.viewModel, required this.exitPopup, + required this.scannerController, + required this.scanEan, }); final AddViewModel viewModel; final Function(BuildContext) exitPopup; + final MobileScannerController scannerController; + final Function scanEan; @override Widget build(BuildContext context) { @@ -31,6 +36,8 @@ class FormPopup extends StatelessWidget { return _ManualEANPopup( exitPopup: exitPopup, viewModel: viewModel, + scannerController: scannerController, + scanEan: scanEan, ); }, ); @@ -51,6 +58,7 @@ class FormPopup extends StatelessWidget { ), ), ), + Card( clipBehavior: Clip.hardEdge, child: InkWell( @@ -93,11 +101,24 @@ class FormPopup extends StatelessWidget { } } +/* + * ====================== + * ====< MANUAL EAN >==== + * ====================== +*/ + class _ManualEANPopup extends StatefulWidget { - const _ManualEANPopup({required this.exitPopup, required this.viewModel}); + const _ManualEANPopup({ + required this.exitPopup, + required this.viewModel, + required this.scannerController, + required this.scanEan, + }); final Function(BuildContext) exitPopup; final AddViewModel viewModel; + final MobileScannerController scannerController; + final Function scanEan; @override State<_ManualEANPopup> createState() => _ManualEANPopupState(); @@ -106,11 +127,11 @@ class _ManualEANPopup extends StatefulWidget { class _ManualEANPopupState extends State<_ManualEANPopup> { final GlobalKey _formKey = GlobalKey(); String? ean; - num? price; + @override Widget build(BuildContext context) { return AlertDialog( - title: Text("Recherche par EAN"), + title: Text("Entrée manuelle par EAN"), content: Form( key: _formKey, child: Column( @@ -128,61 +149,39 @@ class _ManualEANPopupState extends State<_ManualEANPopup> { validator: (value) { if (value == null || value.length != 13 || - int.tryParse(value) == null) { + int.tryParse(value) == null || + int.parse(value) < 0) { return "L'entrée n'est pas un code EAN-13 valide"; } return null; }, ), - SizedBox(height: 10), - ListenableBuilder( - listenable: widget.viewModel, - builder: (context, child) { - return (widget.viewModel.askPrice) - ? TextFormField( - decoration: InputDecoration( - labelText: "Prix", - border: OutlineInputBorder(), - suffixText: "€", - ), - keyboardType: TextInputType.numberWithOptions( - decimal: true, - ), - validator: (value) { - if (value == null || value.isEmpty) { - return "Indiquez un prix"; - } else if (num.tryParse(value) == null) { - return "Le prix doit être un nombre"; - } else if (num.parse(value) < 0) { - return "Le prix doit être positif ou nul"; - } - return null; - }, - onSaved: (newValue) { - price = num.parse(newValue!); - }, - ) - : SizedBox(); - }, - ), ], ), ), actions: [ TextButton( + child: Text("Annuler"), onPressed: () { widget.exitPopup(context); }, - child: Text("Annuler"), ), TextButton( + child: Text("Valider"), onPressed: () { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); - widget.exitPopup(context); + widget.scanEan( + context, + widget.viewModel, + ean!, + widget.scannerController, + leaveLastPopup: (context) { + Navigator.of(context).pop(); + }, + ); } }, - child: Text("Valider"), ), ], ); @@ -279,6 +278,7 @@ class _FullyManualState extends State<_FullyManual> { ), keyboardType: TextInputType.numberWithOptions( decimal: true, + signed: true, ), validator: (value) { if (value == null || value.isEmpty) { diff --git a/lib/ui/add_page/widgets/owner_popup.dart b/lib/ui/add_page/widgets/owner_popup.dart index 35fe4b3..8627c13 100644 --- a/lib/ui/add_page/widgets/owner_popup.dart +++ b/lib/ui/add_page/widgets/owner_popup.dart @@ -6,11 +6,11 @@ class OwnerPopup extends StatefulWidget { const OwnerPopup({ super.key, required this.viewModel, - required this.onPressAccept, + required this.exitPopup, }); final AddViewModel viewModel; - final Function(BuildContext) onPressAccept; + final Function(BuildContext) exitPopup; @override State createState() => _OwnerPopupState(); @@ -166,7 +166,7 @@ class _OwnerPopupState extends State { }); } } - widget.onPressAccept(context); + widget.exitPopup(context); }, child: Text( (!showNewOwner && searchController.text == "") diff --git a/lib/ui/auth/viewmodel/login_view_model.dart b/lib/ui/auth/viewmodel/login_view_model.dart index 190ea46..51179f0 100644 --- a/lib/ui/auth/viewmodel/login_view_model.dart +++ b/lib/ui/auth/viewmodel/login_view_model.dart @@ -13,8 +13,10 @@ class LoginViewModel extends ChangeNotifier { final AuthRepository _authRepository; + /// Command to login with added capabilities late Command1 login; + /// Logins the user with credentials [(String username, String password)] Future> _login((String, String) credentials) async { final (username, password) = credentials; final result = await _authRepository.login(username, password); @@ -27,10 +29,12 @@ class LoginViewModel extends ChangeNotifier { * ================================= */ + /// Loads all necessary data late final Command0 load; bool isLoaded = false; bool isUpToDate = false; + /// Manages loaders Future> _load() async { final result1 = await _loadApiVersion(); switch (result1) { @@ -44,6 +48,7 @@ class LoginViewModel extends ChangeNotifier { return result1; } + /// Loads the current remote api version and compares to local hardcoded [apiVersion] Future> _loadApiVersion() async { final result = await _authRepository.getRemoteApiVersion(); switch (result) { diff --git a/lib/ui/bal_page/view_model/bal_view_model.dart b/lib/ui/bal_page/view_model/bal_view_model.dart index f9947f1..c3cdbb9 100644 --- a/lib/ui/bal_page/view_model/bal_view_model.dart +++ b/lib/ui/bal_page/view_model/bal_view_model.dart @@ -14,7 +14,7 @@ import 'package:seshat/utils/result.dart'; class BalViewModel extends ChangeNotifier { BalViewModel({ required BalRepository balRepository, - required this.id, + required this.selectedBalId, required OwnerRepository ownerRepository, }) : _balRepository = balRepository, _ownerRepository = ownerRepository { @@ -30,31 +30,50 @@ class BalViewModel extends ChangeNotifier { * ===================== */ - Bal? _bal; - int id; - Bal? get bal => _bal; + /// Selected [Bal] + Bal? _selectedBal; + + /// Selected [Bal] + Bal? get selectedBal => _selectedBal; + + /// Selected [Bal.id] from path parameters + int selectedBalId; + + /// Is one of the [Bal] [BalState.ongoing] bool isABalOngoing = false; - Future> stopBal(int id) async { - final result = await _balRepository.stopBal(id); + /// Stops a [Bal] + Future> stopBal(int balId) async { + isLoaded = false; + notifyListeners(); + final result = await _balRepository.stopBal(balId); switch (result) { case Ok(): - _bal = result.value; - notifyListeners(); + _selectedBal = result.value; break; default: } + final result2 = await _loadEnded(); + switch (result2) { + case Ok(): + isLoaded = true; + break; + case Error(): + break; + } + notifyListeners(); return result; } - Future> startBal(int id) async { + /// Starts a [Bal] + Future> startBal(int balId) async { if (isABalOngoing) { return Result.error(Exception("Cannot have multiple BALs ongoing !")); } - final result = await _balRepository.startBal(id); + final result = await _balRepository.startBal(balId); switch (result) { case Ok(): - _bal = result.value; + _selectedBal = result.value; notifyListeners(); break; default: @@ -62,21 +81,20 @@ class BalViewModel extends ChangeNotifier { return result; } + /// Edits a [Bal]'s [name], [startTime] or [endTime] Future> editBal( int id, String name, - DateTime start, - DateTime end, + DateTime startTime, + DateTime endTime, ) async { - final result = await _balRepository.editBal(id, name, start, end); + final result = await _balRepository.editBal(id, name, startTime, endTime); switch (result) { case Ok(): - debugPrint("\n\n\n\nDID EDIT\n\n\n\n"); - _bal = result.value; + _selectedBal = result.value; notifyListeners(); break; case Error(): - debugPrint("\n\n\n\nERROR: ${result.error}"); break; } return result; @@ -89,11 +107,16 @@ class BalViewModel extends ChangeNotifier { */ // Specific to ended state + + /// Owners a book or money is owed to List? owedToOwners; - double? totalOwed; + + /// Statistics about the [_selectedBal] BalStats? stats; - Future applyAccountingOwners( + /// Froms [books], updates [owners] to include all the necessary information + /// See [the api doc](https://bal.ueauvergne.fr/docs/#/bal-api/get_bal_accounting) for more details + Future _updateOwedToOwnersWithBooks( List owners, Map books, ) async { @@ -114,15 +137,20 @@ class BalViewModel extends ChangeNotifier { } } + /// Returns either Books, Money or All ([ReturnType]) to an [Owner] Future> returnById(ReturnType type, int ownerId) async { - final result = await _balRepository.returnToId(id, ownerId, type); - final result2 = await _balRepository.getAccounting(id); + final result = await _balRepository.returnToId( + selectedBalId, + ownerId, + type, + ); + final result2 = await _balRepository.getAccounting(selectedBalId); switch (result2) { case Ok(): - applyAccountingOwners(result2.value.owners, result2.value.books); + _updateOwedToOwnersWithBooks(result2.value.owners, result2.value.books); break; case Error(): - debugPrint(result2.error.toString()); + break; } notifyListeners(); return result; @@ -134,31 +162,31 @@ class BalViewModel extends ChangeNotifier { * ================================= */ + /// Loads all the necessary information late final Command0 load; bool isLoaded = false; + /// Manages loaders Future> _load() async { isABalOngoing = _balRepository.isABalOngoing(); final result1 = await _loadBal(); switch (result1) { case Ok(): - isLoaded = (_bal == null || _bal?.state != BalState.ended) + isLoaded = + (_selectedBal == null || _selectedBal?.state != BalState.ended) ? true : false; break; default: break; } - debugPrint("$isLoaded"); - if (_bal?.state == BalState.ended) { + if (_selectedBal?.state == BalState.ended) { final result2 = await _loadEnded(); - debugPrint("Hello"); switch (result2) { case Ok(): isLoaded = true; break; case Error(): - debugPrint("No ${result2.error}"); break; } } @@ -166,11 +194,12 @@ class BalViewModel extends ChangeNotifier { return result1; } + /// Loads all common [Bal] information Future> _loadBal() async { - final result = await _balRepository.balById(id); + final result = await _balRepository.balById(selectedBalId); switch (result) { case Ok(): - _bal = result.value; + _selectedBal = result.value; break; case Error(): break; @@ -179,15 +208,16 @@ class BalViewModel extends ChangeNotifier { return result; } + /// Loads [Bal] information when it is [BalState.ended] Future> _loadEnded() async { - final result = await _balRepository.getAccountingNoCache(id); + final result = await _balRepository.getAccountingNoCache(selectedBalId); switch (result) { case Ok(): - applyAccountingOwners(result.value.owners, result.value.books); + _updateOwedToOwnersWithBooks(result.value.owners, result.value.books); break; default: } - final result2 = await _balRepository.getBalStats(id); + final result2 = await _balRepository.getBalStats(selectedBalId); switch (result2) { case Ok(): stats = result2.value; diff --git a/lib/ui/bal_page/widget/bal_page.dart b/lib/ui/bal_page/widget/bal_page.dart index 2347dc3..e7e3467 100644 --- a/lib/ui/bal_page/widget/bal_page.dart +++ b/lib/ui/bal_page/widget/bal_page.dart @@ -27,14 +27,14 @@ class _BalPageState extends State { bottomNavigationBar: AppNavigationBar(startIndex: 0), body: AwaitLoading(), ), - true => switch (widget.viewModel.bal == null) { + true => switch (widget.viewModel.selectedBal == null) { true => Scaffold( bottomNavigationBar: AppNavigationBar(startIndex: 0), body: Center( child: Text("La BAL référencée n'est pas accessible"), ), ), - false => switch (widget.viewModel.bal!.state) { + false => switch (widget.viewModel.selectedBal!.state) { BalState.pending => BalPendingScreen(viewModel: widget.viewModel), BalState.ongoing => BalOngoingScreen(viewModel: widget.viewModel), BalState.ended => BalEndedScreen(viewModel: widget.viewModel), diff --git a/lib/ui/bal_page/widget/ended/bal_ended_screen.dart b/lib/ui/bal_page/widget/ended/bal_ended_screen.dart index 70a9905..2b62688 100644 --- a/lib/ui/bal_page/widget/ended/bal_ended_screen.dart +++ b/lib/ui/bal_page/widget/ended/bal_ended_screen.dart @@ -30,12 +30,15 @@ class _BalEndedScreenState extends State return Scaffold( bottomNavigationBar: AppNavigationBar(startIndex: 0), appBar: AppBar( - title: Text(widget.viewModel.bal!.name), + title: Text(widget.viewModel.selectedBal!.name), bottom: TabBar( controller: tabController, tabs: [ Tab(text: "Statistiques"), - Tab(text: "À rendre"), + Tab( + text: + "À rendre (${widget.viewModel.owedToOwners?.length.toString() ?? "0"})", + ), ], ), ), diff --git a/lib/ui/bal_page/widget/ongoing/bal_ongoing_screen.dart b/lib/ui/bal_page/widget/ongoing/bal_ongoing_screen.dart index a9ca41e..db9fd01 100644 --- a/lib/ui/bal_page/widget/ongoing/bal_ongoing_screen.dart +++ b/lib/ui/bal_page/widget/ongoing/bal_ongoing_screen.dart @@ -11,7 +11,7 @@ class BalOngoingScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( bottomNavigationBar: AppNavigationBar(startIndex: 0), - appBar: AppBar(title: Text(viewModel.bal!.name)), + appBar: AppBar(title: Text(viewModel.selectedBal!.name)), body: Padding( padding: const EdgeInsets.all(10.0), child: Column( @@ -38,7 +38,9 @@ class BalOngoingScreen extends StatelessWidget { ), TextButton( onPressed: () async { - await viewModel.stopBal(viewModel.bal!.id); + await viewModel.stopBal( + viewModel.selectedBal!.id, + ); if (context.mounted) { Navigator.of(context).pop(); } diff --git a/lib/ui/bal_page/widget/pending/bal_pending_screen.dart b/lib/ui/bal_page/widget/pending/bal_pending_screen.dart index 2e57c22..e5d7164 100644 --- a/lib/ui/bal_page/widget/pending/bal_pending_screen.dart +++ b/lib/ui/bal_page/widget/pending/bal_pending_screen.dart @@ -15,7 +15,7 @@ class BalPendingScreen extends StatelessWidget { return Scaffold( bottomNavigationBar: AppNavigationBar(startIndex: 0), appBar: AppBar( - title: Text(viewModel.bal!.name), + title: Text(viewModel.selectedBal!.name), actions: [ IconButton( onPressed: () { @@ -57,7 +57,9 @@ class BalPendingScreen extends StatelessWidget { ), TextButton( onPressed: () async { - await viewModel.startBal(viewModel.bal!.id); + await viewModel.startBal( + viewModel.selectedBal!.id, + ); if (context.mounted) { Navigator.of(context).pop(); } @@ -96,8 +98,8 @@ class _EditPopup extends State { firstDate: DateTime(DateTime.now().year - 1), lastDate: DateTime(DateTime.now().year + 2), initialDateRange: DateTimeRange( - start: start ?? widget.viewModel.bal!.startTime, - end: end ?? widget.viewModel.bal!.endTime, + start: start ?? widget.viewModel.selectedBal!.startTime, + end: end ?? widget.viewModel.selectedBal!.endTime, ), ); @@ -126,7 +128,7 @@ class _EditPopup extends State { labelText: "Nom de la BAL", border: OutlineInputBorder(), ), - initialValue: widget.viewModel.bal!.name, + initialValue: widget.viewModel.selectedBal!.name, validator: (value) { if (value == null || value.isEmpty) { return "Veuillez entrer un nom"; @@ -169,7 +171,7 @@ class _EditPopup extends State { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); - final Bal bal = widget.viewModel.bal!; + final Bal bal = widget.viewModel.selectedBal!; final result = await widget.viewModel.editBal( bal.id, diff --git a/lib/ui/home_page/view_model/home_view_model.dart b/lib/ui/home_page/view_model/home_view_model.dart index 29ffeda..e6b68d2 100644 --- a/lib/ui/home_page/view_model/home_view_model.dart +++ b/lib/ui/home_page/view_model/home_view_model.dart @@ -18,18 +18,25 @@ class HomeViewModel extends ChangeNotifier { * ================= */ + /// [List] of all [Bal] List _bals = []; + + /// [List] of all [Bal] List get bals => _bals; - Bal? _currentBal; - Bal? get currentBal => _currentBal; + /// [Bal] currently [BalState.ongoing] + Bal? _ongoingBal; + /// [Bal] currently [BalState.ongoing] + Bal? get ongoingBal => _ongoingBal; + + /// Creates a [Bal] from its [name], [startTime] and [endTime] Future> createBal( String name, - DateTime start, - DateTime end, + DateTime startTime, + DateTime endTime, ) async { - final result = await _balRepository.addBal(name, start, end); + final result = await _balRepository.addBal(name, startTime, endTime); switch (result) { case Ok(): final result2 = await _balRepository.getBals(); @@ -54,9 +61,11 @@ class HomeViewModel extends ChangeNotifier { * ================================= */ + /// Command to load all necessary data late final Command0 load; bool isLoaded = false; + /// Manages loaders Future> _load() async { final result2 = await _loadBal(); switch (result2) { @@ -70,12 +79,13 @@ class HomeViewModel extends ChangeNotifier { return result2; } + /// Loads data about [Bal] Future> _loadBal() async { final result = await _balRepository.getBals(); switch (result) { case Ok(): _bals = result.value..sort((a, b) => a.compareTo(b)); - _currentBal = _bals + _ongoingBal = _bals .where((bal) => bal.state == BalState.ongoing) .firstOrNull; break; diff --git a/lib/ui/home_page/widgets/home_page.dart b/lib/ui/home_page/widgets/home_page.dart index adc6684..2f56dbd 100644 --- a/lib/ui/home_page/widgets/home_page.dart +++ b/lib/ui/home_page/widgets/home_page.dart @@ -38,7 +38,7 @@ class _HomePageState extends State { : ListView( children: [ for (Bal bal in widget.viewModel.bals.where( - (el) => el.id != widget.viewModel.currentBal?.id, + (el) => el.id != widget.viewModel.ongoingBal?.id, )) Padding( padding: const EdgeInsets.symmetric( @@ -81,20 +81,20 @@ class _HomePageState extends State { ], ), ), - switch (widget.viewModel.currentBal == null) { + switch (widget.viewModel.ongoingBal == null) { true => SizedBox(), false => Padding( padding: const EdgeInsets.symmetric(horizontal: 10.0), child: Card( child: ListTile( leading: Icon(Icons.event_available), - title: Text(widget.viewModel.currentBal!.name), + title: Text(widget.viewModel.ongoingBal!.name), subtitle: Text("BAL en cours"), trailing: IconButton( onPressed: () { _moveToBal( context, - widget.viewModel.currentBal!.id, + widget.viewModel.ongoingBal!.id, ); }, icon: Icon(Icons.arrow_forward), diff --git a/lib/ui/sell_page/view_model/sell_view_model.dart b/lib/ui/sell_page/view_model/sell_view_model.dart index 549b328..77b0393 100644 --- a/lib/ui/sell_page/view_model/sell_view_model.dart +++ b/lib/ui/sell_page/view_model/sell_view_model.dart @@ -31,10 +31,13 @@ class SellViewModel extends ChangeNotifier { final BookRepository _bookRepository; final OwnerRepository _ownerRepository; - bool _showScan = false; - bool get showScan => _showScan; - set showScan(bool newValue) { - _showScan = newValue; + /// Wether to show the scan screen + bool _showScanScreen = false; + + /// Wether to show the scan screen + bool get showScanScreen => _showScanScreen; + set showScanScreen(bool newValue) { + _showScanScreen = newValue; notifyListeners(); } @@ -44,56 +47,66 @@ class SellViewModel extends ChangeNotifier { * =============================== */ - final List _soldBooks = []; - List get soldBooks => _soldBooks; + /// Books in the sell + final List _booksInSell = []; + /// Books in the sell + List get booksInSell => _booksInSell; + + /// Books scanned on the scan screen final List _scannedBooks = []; + + /// Books scanned on the scan screen List get scannedBooks => _scannedBooks; bool isScanLoaded = false; bool isSendingSell = false; - double minimumAmount = 0; + double minimumAmountToPay = 0; - void sellBook(BookStack addedBook) { - minimumAmount += addedBook.instance.price; - _soldBooks.add(addedBook); + /// Adds a book to the [_booksInSell] + void addBookToSell(BookStack bookToAdd) { + minimumAmountToPay += bookToAdd.instance.price; + _booksInSell.add(bookToAdd); notifyListeners(); } - void sendSell(double givenAmount) async { + /// Sends the sell + void sendSell(double givenMoney) async { isSendingSell = true; notifyListeners(); - List toSend = []; - int nbOfPl = 0; - for (BookStack book in _soldBooks) { + List booksToSend = []; + int numberOfPL = 0; + for (BookStack book in _booksInSell) { if (book.instance.price != 0) { book.instance.soldPrice = book.instance.price; - givenAmount -= book.instance.price; - toSend.add(book.instance); + givenMoney -= book.instance.price; + booksToSend.add(book.instance); } else { - nbOfPl++; + numberOfPL++; } } - if (nbOfPl != 0) { - double amountPerPl = givenAmount / nbOfPl; - for (BookStack book in _soldBooks) { + if (numberOfPL != 0) { + double moneyPerPL = givenMoney / numberOfPL; + for (BookStack book in _booksInSell) { if (book.instance.price == 0) { - book.instance.soldPrice = amountPerPl; - toSend.add(book.instance); + book.instance.soldPrice = moneyPerPL; + booksToSend.add(book.instance); } } } - await _bookInstanceRepository.sellBooks(toSend); - _soldBooks.clear(); + await _bookInstanceRepository.sellBooks(booksToSend); + _booksInSell.clear(); isSendingSell = false; notifyListeners(); } - void deleteBook(int id) { - _soldBooks.removeWhere((book) => book.instance.id == id); + /// Removes a book from the sell + void removeBookFromSell(int bookId) { + _booksInSell.removeWhere((book) => book.instance.id == bookId); notifyListeners(); } + /// Search a book by [title] or [author] Future searchBook(String title, String author) async { Bal? bal = await _balRepository.ongoingBal(); isScanLoaded = false; @@ -106,15 +119,21 @@ class SellViewModel extends ChangeNotifier { ); switch (result) { case Ok(): + // For each result value, you need to complete some values for (SearchResult searchResult in result.value) { + // In case you get a book that's actually not available if (searchResult.instance.available == false) { continue; } - if (_soldBooks + + // In case the instance is already in the sell + if (_booksInSell .where((book) => book.instance.id == searchResult.instance.id) .isNotEmpty) { continue; } + + // Search for the owner Owner owner; final result2 = await _ownerRepository.getOwnerById( searchResult.instance.ownerId, @@ -126,6 +145,7 @@ class SellViewModel extends ChangeNotifier { case Error(): continue; } + _scannedBooks.add( BookStack(searchResult.book, searchResult.instance, owner), ); @@ -140,18 +160,19 @@ class SellViewModel extends ChangeNotifier { return; } + /// Gets [BookInstance]s from its ean in a [barcode] Future scanBook(BarcodeCapture barcode) async { isScanLoaded = false; int ean = int.parse(barcode.barcodes.first.rawValue!); - Bal? bal = await _balRepository.ongoingBal(); + Bal? ongoingBal = await _balRepository.ongoingBal(); _scannedBooks.clear(); - final result = await _bookInstanceRepository.getByEan(bal!.id, ean); - switch (result) { + final result1 = await _bookInstanceRepository.getByEan(ongoingBal!.id, ean); + switch (result1) { case Ok(): Book book; final result2 = await _bookRepository.getBookById( - result.value.first.bookId, + result1.value.first.bookId, ); switch (result2) { case Ok(): @@ -160,15 +181,22 @@ class SellViewModel extends ChangeNotifier { case Error(): return; } - for (BookInstance instance in result.value) { + + // For each result value, you need to complete some values + for (BookInstance instance in result1.value) { + // In case you get a book that's actually not available if (instance.available == false) { continue; } - if (_soldBooks + + // In case the instance is already in the sell + if (_booksInSell .where((book) => book.instance.id == instance.id) .isNotEmpty) { continue; } + + // Search for the owner Owner owner; final result3 = await _ownerRepository.getOwnerById(instance.ownerId); switch (result3) { @@ -178,6 +206,7 @@ class SellViewModel extends ChangeNotifier { case Error(): continue; } + _scannedBooks.add(BookStack(book, instance, owner)); } break; @@ -196,8 +225,11 @@ class SellViewModel extends ChangeNotifier { * ================= */ - Bal? _currentBal; - get currentBal => _currentBal; + /// The currently ongoing [Bal] + Bal? _ongoingBal; + + /// The currently ongoing [Bal] + get ongoingBal => _ongoingBal; /* * ================================= @@ -205,9 +237,11 @@ class SellViewModel extends ChangeNotifier { * ================================= */ + /// Command to load necessary data late final Command0 load; bool isLoaded = false; + /// Manages loaders Future> _load() async { final result1 = await _loadBal(); switch (result1) { @@ -221,11 +255,12 @@ class SellViewModel extends ChangeNotifier { return result1; } + /// Loads information about [Bal] Future> _loadBal() async { final result = await _balRepository.getBals(); switch (result) { case Ok(): - _currentBal = result.value + _ongoingBal = result.value .where((bal) => bal.state == BalState.ongoing) .firstOrNull; break; diff --git a/lib/ui/sell_page/widgets/scan_screen.dart b/lib/ui/sell_page/widgets/scan_screen.dart index ceb9920..3036fb8 100644 --- a/lib/ui/sell_page/widgets/scan_screen.dart +++ b/lib/ui/sell_page/widgets/scan_screen.dart @@ -65,7 +65,7 @@ class _ScanScreenState extends State { children: [ IconButton( onPressed: () { - widget.viewModel.showScan = false; + widget.viewModel.showScanScreen = false; }, icon: Icon(Icons.arrow_back), ), diff --git a/lib/ui/sell_page/widgets/sell_choice_popup.dart b/lib/ui/sell_page/widgets/sell_choice_popup.dart index 1931a86..f96bb93 100644 --- a/lib/ui/sell_page/widgets/sell_choice_popup.dart +++ b/lib/ui/sell_page/widgets/sell_choice_popup.dart @@ -34,9 +34,9 @@ class SellChoicePopup extends StatelessWidget { child: Card( child: InkWell( onTap: () { - viewModel.sellBook(book); + viewModel.addBookToSell(book); Navigator.of(context).pop(); - viewModel.showScan = false; + viewModel.showScanScreen = false; }, child: ListTile( leading: Text( diff --git a/lib/ui/sell_page/widgets/sell_page.dart b/lib/ui/sell_page/widgets/sell_page.dart index 0ae364e..784ff23 100644 --- a/lib/ui/sell_page/widgets/sell_page.dart +++ b/lib/ui/sell_page/widgets/sell_page.dart @@ -28,7 +28,7 @@ class _SellPageState extends State { builder: (context, child) { return switch (widget.viewModel.isLoaded) { false => AwaitLoading(), - true => switch (widget.viewModel.currentBal) { + true => switch (widget.viewModel.ongoingBal) { null => Center( child: SizedBox( width: 300, @@ -80,7 +80,7 @@ class _SellPageState extends State { ? Center(child: Text("Aucun")) : SizedBox(), for (BookStack book - in widget.viewModel.soldBooks) + in widget.viewModel.booksInSell) Padding( padding: const EdgeInsets.symmetric( horizontal: 15, @@ -99,9 +99,10 @@ class _SellPageState extends State { ), trailing: IconButton( onPressed: () { - widget.viewModel.deleteBook( - book.instance.id, - ); + widget.viewModel + .removeBookFromSell( + book.instance.id, + ); }, icon: Icon(Icons.delete), ), @@ -113,7 +114,7 @@ class _SellPageState extends State { ), SizedBox(height: 40), Text( - "Montant minimum à payer : ${widget.viewModel.minimumAmount.toString()}€", + "Montant minimum à payer : ${widget.viewModel.minimumAmountToPay.toString()}€", ), Padding( padding: const EdgeInsets.symmetric(horizontal: 60.0), @@ -122,7 +123,6 @@ class _SellPageState extends State { controller: price, decoration: InputDecoration( labelText: "Argent reçu", - hintText: "Les décimales sont avec des .", helperText: "L'argent reçu sera réparti automatiquement.", suffixText: "€", @@ -130,6 +130,7 @@ class _SellPageState extends State { ), keyboardType: TextInputType.numberWithOptions( decimal: true, + signed: true, ), ), ), @@ -140,7 +141,21 @@ class _SellPageState extends State { children: [ IconButton( onPressed: () { - if (double.tryParse(price.text) == null) { + if (widget.viewModel.scannedBooks.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "La vente doit comporter au moins un livre", + ), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + if (double.tryParse( + price.text.replaceFirst(",", "."), + ) == + null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -150,8 +165,10 @@ class _SellPageState extends State { ), ); return; - } else if (double.parse(price.text) < - widget.viewModel.minimumAmount) { + } else if (double.parse( + price.text.replaceFirst(",", "."), + ) < + widget.viewModel.minimumAmountToPay) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -163,7 +180,9 @@ class _SellPageState extends State { return; } widget.viewModel.sendSell( - double.parse(price.text), + double.parse( + price.text.replaceFirst(",", "."), + ), ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -184,7 +203,7 @@ class _SellPageState extends State { SizedBox(width: 70), IconButton( onPressed: () { - widget.viewModel.showScan = true; + widget.viewModel.showScanScreen = true; }, icon: Icon(Icons.add), style: ButtonStyle( @@ -198,7 +217,7 @@ class _SellPageState extends State { ], ), ), - (widget.viewModel.showScan) + (widget.viewModel.showScanScreen) ? ScanScreen(viewModel: widget.viewModel) : SizedBox(), ],