From 073f8bd33478bdccda358f953680455f89ae5f2c Mon Sep 17 00:00:00 2001 From: Alzalia Date: Fri, 8 Aug 2025 19:42:50 +0200 Subject: [PATCH] feat: add an owner + sell screen --- lib/config/dependencies.dart | 2 +- .../book_instance_repository.dart | 1 + lib/data/repositories/book_repository.dart | 1 + lib/data/repositories/owner_repository.dart | 24 ++-- lib/data/services/api_client.dart | 73 +++++++---- lib/routing/router.dart | 8 +- lib/routing/routes.dart | 3 - .../add_page/view_model/add_view_model.dart | 18 +-- lib/ui/add_page/widgets/add_page.dart | 5 +- lib/ui/add_page/widgets/owner_popup.dart | 18 ++- lib/ui/sell_page/sell_page.dart | 16 --- .../sell_page/view_model/sell_view_model.dart | 39 ++++++ .../sell_page/widgets/manual_scan_popup.dart | 39 ++++++ lib/ui/sell_page/widgets/scan_screen.dart | 73 +++++++++++ lib/ui/sell_page/widgets/sell_page.dart | 116 ++++++++++++++++++ 15 files changed, 354 insertions(+), 82 deletions(-) create mode 100644 lib/data/repositories/book_instance_repository.dart create mode 100644 lib/data/repositories/book_repository.dart delete mode 100644 lib/ui/sell_page/sell_page.dart create mode 100644 lib/ui/sell_page/view_model/sell_view_model.dart create mode 100644 lib/ui/sell_page/widgets/manual_scan_popup.dart create mode 100644 lib/ui/sell_page/widgets/scan_screen.dart create mode 100644 lib/ui/sell_page/widgets/sell_page.dart diff --git a/lib/config/dependencies.dart b/lib/config/dependencies.dart index 3236d47..95a5787 100644 --- a/lib/config/dependencies.dart +++ b/lib/config/dependencies.dart @@ -10,7 +10,7 @@ import "package:seshat/data/services/websocket_client.dart"; List get providers { return [ Provider(create: (context) => AuthClient()), - Provider(create: (context) => ApiClient(authClient: context.read())), + Provider(create: (context) => ApiClient()), Provider(create: (context) => WebsocketClient()), Provider( create: (context) => diff --git a/lib/data/repositories/book_instance_repository.dart b/lib/data/repositories/book_instance_repository.dart new file mode 100644 index 0000000..a4a8b47 --- /dev/null +++ b/lib/data/repositories/book_instance_repository.dart @@ -0,0 +1 @@ +class BookInstanceRepository {} diff --git a/lib/data/repositories/book_repository.dart b/lib/data/repositories/book_repository.dart new file mode 100644 index 0000000..0c4efee --- /dev/null +++ b/lib/data/repositories/book_repository.dart @@ -0,0 +1 @@ +class BookRepository {} diff --git a/lib/data/repositories/owner_repository.dart b/lib/data/repositories/owner_repository.dart index dd89d64..8245e8d 100644 --- a/lib/data/repositories/owner_repository.dart +++ b/lib/data/repositories/owner_repository.dart @@ -1,8 +1,5 @@ import 'dart:async'; -import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:seshat/data/services/api_client.dart'; import 'package:seshat/data/services/websocket_client.dart'; import 'package:seshat/domain/models/owner.dart'; @@ -17,22 +14,25 @@ class OwnerRepository { final ApiClient _apiClient; final WebsocketClient _wsClient; - final BehaviorSubject _ownersController = BehaviorSubject( - sync: true, - ); late final StreamSubscription sub; List? _cachedOwners; - Future> postOwner( + /// Adds an [Owner] to the database, and gets the resulting [Owner]. + Future> addOwner( String firstName, String lastName, String contact, ) async { - return Result.ok( - Owner(firstName: firstName, lastName: lastName, contact: contact, id: 50), - ); + var response = await _apiClient.addOwner(firstName, lastName, contact); + switch (response) { + case Ok(): + return Result.ok(response.value); + case Error(): + return Result.error(response.error); + } } + /// Fetches all the [Owner]s from the database, and subscribes to updates Future>> getOwners() async { if (_cachedOwners == null) { final result = await _apiClient.getOwners(); @@ -43,9 +43,7 @@ class OwnerRepository { } sub = _wsClient.owners.listen((owner) { - debugPrint("\n\n\n\n[3] Added : $owner\n\n\n\n"); _cachedOwners!.add(owner); - _ownersController.add(owner); }); return result; @@ -54,8 +52,6 @@ class OwnerRepository { } } - Stream get liveOwners => _ownersController.stream; - dispose() { sub.cancel(); } diff --git a/lib/data/services/api_client.dart b/lib/data/services/api_client.dart index 24a1738..fb6a496 100644 --- a/lib/data/services/api_client.dart +++ b/lib/data/services/api_client.dart @@ -1,10 +1,8 @@ import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.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'; @@ -12,14 +10,8 @@ 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; + ApiClient({String? host, int? port}); - final AuthClient _authClient; late final Command0 load; String? token; bool isReady = false; @@ -31,26 +23,65 @@ class ApiClient { ); } + /* + * ==================== + * =====[ OWNERS ]===== + * ==================== +*/ + Future>> getOwners() async { - final client = HttpClient(); + final client = Client(); 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(); + final headers = {"Authorization": "Bearer $token"}; + final response = await client.get( + Uri.parse("https://$apiBasePath/owners"), + headers: headers, + ); if (response.statusCode == 200) { - final stringData = await response.transform(Utf8Decoder()).join(); - final json = jsonDecode(stringData) as List; + final json = jsonDecode(response.body) as List; return Result.ok( json.map((element) => Owner.fromJSON(element)).toList(), ); } else { - return const Result.error(HttpException("Invalid response")); + return Result.error(Exception("Invalid request")); + } + } on Exception catch (error) { + return Result.error(error); + } finally { + client.close(); + } + } + + Future> addOwner( + String firstName, + String lastName, + String contact, + ) async { + final client = Client(); + try { + await _initStore(); + final token = await _secureStorage!.read(key: "token"); + final headers = { + "Authorization": "Bearer $token", + "Content-Type": "application/json", + }; + final body = { + "first_name": firstName, + "last_name": lastName, + "contact": contact, + }; + final response = await client.post( + Uri.parse("https://$apiBasePath/owner"), + headers: headers, + body: jsonEncode(body), + ); + if (response.statusCode == 201) { + final json = jsonDecode(response.body); + return Result.ok(Owner.fromJSON(json)); + } else { + return Result.error(Exception("Invalid request")); } } on Exception catch (error) { return Result.error(error); diff --git a/lib/routing/router.dart b/lib/routing/router.dart index 6f56d6a..c60e2a7 100644 --- a/lib/routing/router.dart +++ b/lib/routing/router.dart @@ -7,7 +7,8 @@ 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'; +import 'package:seshat/ui/sell_page/view_model/sell_view_model.dart'; +import 'package:seshat/ui/sell_page/widgets/sell_page.dart'; GoRouter router(AuthRepository authRepository) => GoRouter( initialLocation: Routes.add, @@ -45,7 +46,10 @@ GoRouter router(AuthRepository authRepository) => GoRouter( ), GoRoute( path: Routes.sell, - pageBuilder: (context, state) => NoTransitionPage(child: SellPage()), + pageBuilder: (context, state) { + final viewModel = SellViewModel(); + return NoTransitionPage(child: SellPage(viewModel: viewModel)); + }, ), GoRoute( path: Routes.login, diff --git a/lib/routing/routes.dart b/lib/routing/routes.dart index 301929d..39b52b0 100644 --- a/lib/routing/routes.dart +++ b/lib/routing/routes.dart @@ -4,9 +4,6 @@ abstract final class Routes { // ==[ ADD ]== static const add = '/add'; - static const addOwner = '/add/owner'; - static const addPrice = '/add/price'; - static const addForm = '/add/form'; // ==[ SELL ]== static const sell = '/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 882221d..783b27e 100644 --- a/lib/ui/add_page/view_model/add_view_model.dart +++ b/lib/ui/add_page/view_model/add_view_model.dart @@ -32,15 +32,14 @@ class AddViewModel extends ChangeNotifier { } List _owners = []; - List? get owners => _owners; - Future> addOwner( String firstName, String lastName, String contact, ) async { - final result = await _ownerRepository.postOwner( + debugPrint("\n\n\n\n(2) TRANFERRING\n\n\n\n"); + final result = await _ownerRepository.addOwner( firstName, lastName, contact, @@ -52,7 +51,9 @@ class AddViewModel extends ChangeNotifier { switch (secondResult) { case Ok(): + debugPrint("\n\n\n${secondResult.value.length}"); _owners = secondResult.value; + debugPrint("\n\n\n${_owners.length}"); _currentOwner = result.value; notifyListeners(); return Result.ok(result.value); @@ -130,16 +131,7 @@ class AddViewModel extends ChangeNotifier { debugPrint("Oupsie daysie, ${result.error}"); } notifyListeners(); - sub = _ownerRepository.liveOwners.listen((Owner owner) { - debugPrint("\n\n\n\n[5] Updated UI : $owner\n\n\n\n"); - _owners.add(owner); - _owners.sort( - (a, b) => "${a.firstName} ${a.lastName}".compareTo( - "${b.firstName} ${b.lastName}", - ), - ); - notifyListeners(); - }); + return result; } diff --git a/lib/ui/add_page/widgets/add_page.dart b/lib/ui/add_page/widgets/add_page.dart index c0b9987..b74ff70 100644 --- a/lib/ui/add_page/widgets/add_page.dart +++ b/lib/ui/add_page/widgets/add_page.dart @@ -91,6 +91,7 @@ class _AddPageState extends State { crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ + SizedBox(height: 5), Center( child: Card( margin: EdgeInsets.symmetric(horizontal: 50), @@ -132,12 +133,11 @@ class _AddPageState extends State { ), ), ), - SizedBox(height: 100), - SvgPicture.asset('assets/scan-overlay.svg'), ], ), ), ), + Center(child: SvgPicture.asset('assets/scan-overlay.svg')), SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.end, @@ -157,6 +157,7 @@ class _AddPageState extends State { child: Text("Enregistrer manuellement"), ), ), + SizedBox(height: 5), ], ), ), diff --git a/lib/ui/add_page/widgets/owner_popup.dart b/lib/ui/add_page/widgets/owner_popup.dart index 2fdfcea..15111b3 100644 --- a/lib/ui/add_page/widgets/owner_popup.dart +++ b/lib/ui/add_page/widgets/owner_popup.dart @@ -18,6 +18,7 @@ class OwnerPopup extends StatefulWidget { class _OwnerPopupState extends State { final GlobalKey _formKey = GlobalKey(); + final TextEditingController searchController = TextEditingController(); bool showNewOwner = false; String? firstName; String? lastName; @@ -25,6 +26,9 @@ class _OwnerPopupState extends State { @override Widget build(BuildContext context) { + searchController.text = (widget.viewModel.currentOwner == null) + ? "" + : "${widget.viewModel.currentOwner!.firstName} ${widget.viewModel.currentOwner!.lastName}"; final theme = Theme.of(context); return ListenableBuilder( listenable: widget.viewModel, @@ -35,19 +39,14 @@ class _OwnerPopupState extends State { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Center( - child: Text( - (widget.viewModel.currentOwner == null) - ? "Choix actuel : aucun" - : "Choix actuel : ${widget.viewModel.currentOwner!.firstName} ${widget.viewModel.currentOwner!.lastName}", - ), - ), SizedBox(height: 5), (showNewOwner || widget.viewModel.owners!.isEmpty) ? SizedBox() : DropdownMenu( enableFilter: true, + controller: searchController, label: Text("Rechercher un·e propriétaire"), + requestFocusOnTap: true, dropdownMenuEntries: [ for (var owner in widget.viewModel.owners!) DropdownMenuEntry( @@ -65,7 +64,6 @@ class _OwnerPopupState extends State { ), ), ], - initialSelection: widget.viewModel.currentOwner, onSelected: (Owner? owner) { widget.viewModel.currentOwner = owner; }, @@ -151,6 +149,7 @@ class _OwnerPopupState extends State { if (showNewOwner) { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); + debugPrint("\n\n\n\n(1) SENDING REQUEST\n\n\n\n"); await widget.viewModel.addOwner( firstName!, lastName!, @@ -160,9 +159,8 @@ class _OwnerPopupState extends State { showNewOwner = false; }); } - } else { - widget.onPressAccept(context); } + widget.onPressAccept(context); }, child: Text("Valider"), ), diff --git a/lib/ui/sell_page/sell_page.dart b/lib/ui/sell_page/sell_page.dart deleted file mode 100644 index 4f6173a..0000000 --- a/lib/ui/sell_page/sell_page.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:seshat/ui/core/ui/navigation_bar.dart'; - -class SellPage extends StatelessWidget { - const SellPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - bottomNavigationBar: AppNavigationBar(startIndex: 2), - body: Center(child: Text("Sell page.")), - ); - // return Center(child: Text("Sell page.")); - } -} diff --git a/lib/ui/sell_page/view_model/sell_view_model.dart b/lib/ui/sell_page/view_model/sell_view_model.dart new file mode 100644 index 0000000..de55e0d --- /dev/null +++ b/lib/ui/sell_page/view_model/sell_view_model.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:seshat/domain/models/book_instance.dart'; + +class SellViewModel extends ChangeNotifier { + SellViewModel(); + + bool _showScan = false; + bool get showScan => _showScan; + set showScan(bool newValue) { + _showScan = newValue; + notifyListeners(); + } + + final List _scannedBooks = []; + get scannedBooks => _scannedBooks; + void scanBook(BarcodeCapture barcode) { + final addedBook = BookInstance( + balId: 5, + bookId: 5, + id: _scannedBooks.length, + ownerId: 5, + price: 5, + status: true, + ); + _scannedBooks.add(addedBook); + notifyListeners(); + } + + void sendSell() { + _scannedBooks.clear(); + notifyListeners(); + } + + void deleteBook(int id) { + _scannedBooks.removeWhere((book) => book.id == id); + notifyListeners(); + } +} diff --git a/lib/ui/sell_page/widgets/manual_scan_popup.dart b/lib/ui/sell_page/widgets/manual_scan_popup.dart new file mode 100644 index 0000000..fbbc3b7 --- /dev/null +++ b/lib/ui/sell_page/widgets/manual_scan_popup.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:seshat/ui/sell_page/view_model/sell_view_model.dart'; + +class ManualScanPopup extends StatelessWidget { + ManualScanPopup({required this.viewModel}); + + final SellViewModel viewModel; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text("Recherche manuelle"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: InputDecoration( + labelText: "Rechercher", + suffixIcon: IconButton( + onPressed: () {}, + icon: Icon(Icons.arrow_forward), + ), + border: OutlineInputBorder(), + ), + ), + SizedBox(height: 200), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text("Annuler"), + ), + ], + ); + } +} diff --git a/lib/ui/sell_page/widgets/scan_screen.dart b/lib/ui/sell_page/widgets/scan_screen.dart new file mode 100644 index 0000000..f083ab3 --- /dev/null +++ b/lib/ui/sell_page/widgets/scan_screen.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:seshat/ui/sell_page/view_model/sell_view_model.dart'; +import 'package:seshat/ui/sell_page/widgets/manual_scan_popup.dart'; + +class ScanScreen extends StatefulWidget { + ScanScreen({super.key, required this.viewModel}); + + SellViewModel viewModel; + + @override + State createState() => _ScanScreenState(); +} + +class _ScanScreenState extends State { + final MobileScannerController controller = MobileScannerController( + formats: [BarcodeFormat.ean13], + detectionTimeoutMs: 1000, + ); + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Stack( + children: [ + MobileScanner( + controller: controller, + onDetect: (barcodes) async { + widget.viewModel.showScan = false; + widget.viewModel.scanBook(barcodes); + controller.dispose(); + }, + ), + SafeArea( + child: Column( + children: [ + IconButton( + onPressed: () { + widget.viewModel.showScan = false; + }, + icon: Icon(Icons.arrow_back), + ), + ], + ), + ), + Center(child: SvgPicture.asset('assets/scan-overlay.svg')), + Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Center( + child: TextButton( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(theme.cardColor), + ), + onPressed: () { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => + ManualScanPopup(viewModel: widget.viewModel), + ); + }, + child: Text("Vendre un livre sans scanner"), + ), + ), + SizedBox(height: 5), + ], + ), + ], + ); + } +} diff --git a/lib/ui/sell_page/widgets/sell_page.dart b/lib/ui/sell_page/widgets/sell_page.dart new file mode 100644 index 0000000..6fd69df --- /dev/null +++ b/lib/ui/sell_page/widgets/sell_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:seshat/domain/models/book_instance.dart'; +import 'package:seshat/ui/core/ui/navigation_bar.dart'; +import 'package:seshat/ui/sell_page/view_model/sell_view_model.dart'; +import 'package:seshat/ui/sell_page/widgets/scan_screen.dart'; + +class SellPage extends StatefulWidget { + const SellPage({super.key, required this.viewModel}); + + final SellViewModel viewModel; + + @override + State createState() => _SellPageState(); +} + +class _SellPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + bottomNavigationBar: AppNavigationBar(startIndex: 2), + body: ListenableBuilder( + listenable: widget.viewModel, + builder: (context, child) { + return Stack( + children: [ + SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: 6), + Expanded( + child: ListView( + children: [ + for (BookInstance bookInstance + in widget.viewModel.scannedBooks) + Card( + child: ListTile( + leading: Text( + "${bookInstance.price.toString()}€", + style: TextStyle(fontSize: 30), + ), + title: Text( + "Les chiens et la charrue · Patrick K. Dewdney ${bookInstance.id}", + ), + subtitle: Text("Union Étudiante Auvergne"), + trailing: IconButton( + onPressed: () { + widget.viewModel.deleteBook( + bookInstance.id, + ); + }, + icon: Icon(Icons.delete), + ), + ), + ), + ], + ), + ), + SizedBox(height: 40), + Text("Somme minimum requise : 20€"), + SizedBox( + width: 400, + child: TextField( + decoration: InputDecoration( + labelText: "Argent reçu", + helperText: + "L'argent reçu sera réparti automatiquement", + suffixText: "€", + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + ), + ), + SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () { + widget.viewModel.sendSell(); + }, + icon: Icon(Icons.check), + style: ButtonStyle( + iconSize: WidgetStatePropertyAll(70), + ), + ), + SizedBox(width: 70), + IconButton( + onPressed: () { + widget.viewModel.showScan = true; + }, + icon: Icon(Icons.add), + style: ButtonStyle( + iconSize: WidgetStatePropertyAll(70), + elevation: WidgetStatePropertyAll(50), + ), + ), + ], + ), + SizedBox(height: 5), + ], + ), + ), + + (widget.viewModel.showScan) + ? ScanScreen(viewModel: widget.viewModel) + : SizedBox(), + ], + ); + }, + ), + ); + // return Center(child: Text("Sell page.")); + } +}