diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 6dbfa36..61915af 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -34,6 +34,13 @@ class ApiEndpoints { /// GET: (requires auth token) static const String profile = '$apiVersion/auth/profile'; + // ==================== User Endpoints ==================== + + /// Get all users (short info) + /// GET: /PortalUser/GetAllMemberUserShortInfo (requires auth token) + /// Response: List of users + static const String users = '/PortalUser/GetAllMemberUserShortInfo'; + // ==================== Warehouse Endpoints ==================== /// Get all warehouses diff --git a/lib/core/di/providers.dart b/lib/core/di/providers.dart index 86c4790..398c45a 100644 --- a/lib/core/di/providers.dart +++ b/lib/core/di/providers.dart @@ -18,6 +18,14 @@ import '../../features/warehouse/data/repositories/warehouse_repository_impl.dar import '../../features/warehouse/domain/repositories/warehouse_repository.dart'; import '../../features/warehouse/domain/usecases/get_warehouses_usecase.dart'; import '../../features/warehouse/presentation/providers/warehouse_provider.dart'; +import '../../features/users/data/datasources/users_local_datasource.dart'; +import '../../features/users/data/datasources/users_remote_datasource.dart'; +import '../../features/users/data/repositories/users_repository_impl.dart'; +import '../../features/users/domain/repositories/users_repository.dart'; +import '../../features/users/domain/usecases/get_users_usecase.dart'; +import '../../features/users/domain/usecases/sync_users_usecase.dart'; +import '../../features/users/presentation/providers/users_provider.dart'; +import '../../features/users/presentation/providers/users_state.dart'; import '../network/api_client.dart'; import '../storage/secure_storage.dart'; @@ -392,6 +400,105 @@ final productDetailErrorProvider = Provider.family((ref, key) { return state.error; }); +/// ======================================================================== +/// USERS FEATURE PROVIDERS +/// ======================================================================== +/// Providers for users feature following clean architecture + +// Data Layer + +/// Users local data source provider +/// Handles local storage operations for users using Hive +final usersLocalDataSourceProvider = Provider((ref) { + return UsersLocalDataSourceImpl(); +}); + +/// Users remote data source provider +/// Handles API calls for users +final usersRemoteDataSourceProvider = Provider((ref) { + final apiClient = ref.watch(apiClientProvider); + return UsersRemoteDataSourceImpl(apiClient); +}); + +/// Users repository provider +/// Implements domain repository interface +/// Coordinates between local and remote data sources +final usersRepositoryProvider = Provider((ref) { + final remoteDataSource = ref.watch(usersRemoteDataSourceProvider); + final localDataSource = ref.watch(usersLocalDataSourceProvider); + return UsersRepositoryImpl( + remoteDataSource: remoteDataSource, + localDataSource: localDataSource, + ); +}); + +// Domain Layer + +/// Get users use case provider +/// Encapsulates user fetching business logic (local-first strategy) +final getUsersUseCaseProvider = Provider((ref) { + final repository = ref.watch(usersRepositoryProvider); + return GetUsersUseCase(repository); +}); + +/// Sync users use case provider +/// Encapsulates user syncing business logic (force refresh from API) +final syncUsersUseCaseProvider = Provider((ref) { + final repository = ref.watch(usersRepositoryProvider); + return SyncUsersUseCase(repository); +}); + +// Presentation Layer + +/// Users state notifier provider +/// Manages users state including list, loading, and errors +final usersProvider = StateNotifierProvider((ref) { + final getUsersUseCase = ref.watch(getUsersUseCaseProvider); + final syncUsersUseCase = ref.watch(syncUsersUseCaseProvider); + return UsersNotifier( + getUsersUseCase: getUsersUseCase, + syncUsersUseCase: syncUsersUseCase, + ); +}); + +/// Convenient providers for users state + +/// Provider to get list of users +/// Usage: ref.watch(usersListProvider) +final usersListProvider = Provider((ref) { + final usersState = ref.watch(usersProvider); + return usersState.users; +}); + +/// Provider to check if users are loading +/// Usage: ref.watch(isUsersLoadingProvider) +final isUsersLoadingProvider = Provider((ref) { + final usersState = ref.watch(usersProvider); + return usersState.isLoading; +}); + +/// Provider to check if users list has items +/// Usage: ref.watch(hasUsersProvider) +final hasUsersProvider = Provider((ref) { + final usersState = ref.watch(usersProvider); + return usersState.users.isNotEmpty; +}); + +/// Provider to get users count +/// Usage: ref.watch(usersCountProvider) +final usersCountProvider = Provider((ref) { + final usersState = ref.watch(usersProvider); + return usersState.users.length; +}); + +/// Provider to get users error +/// Returns null if no error +/// Usage: ref.watch(usersErrorProvider) +final usersErrorProvider = Provider((ref) { + final usersState = ref.watch(usersProvider); + return usersState.error; +}); + /// ======================================================================== /// USAGE EXAMPLES /// ======================================================================== diff --git a/lib/docs/api.sh b/lib/docs/api.sh index ea20d43..7c3799f 100644 --- a/lib/docs/api.sh +++ b/lib/docs/api.sh @@ -110,4 +110,21 @@ curl 'https://dotnet.elidev.info:8157/ws/portalWareHouse/createProductWareHouse' -H 'Sec-Fetch-Mode: cors' \ -H 'Sec-Fetch-Site: same-site' \ -H 'Priority: u=0' \ - --data-raw $'[{"TypeId":4,"ProductId":11,"StageId":3,"OrderId":null,"RecordDate":"2025-10-28T08:19:20.418Z","PassedQuantityWeight":0.5,"PassedQuantity":5,"IssuedQuantityWeight":0.1,"IssuedQuantity":1,"ResponsibleUserId":12043,"Description":"","ProductName":"Th\xe9p 435","ProductCode":"SCM435","StockPassedQuantityWeight":0,"StockPassedQuantity":0,"StockIssuedQuantity":0,"StockIssuedQuantityWeight":0,"ReceiverUserId":12120,"ActionTypeId":1,"WareHouseId":1,"ProductStageId":3,"IsConfirm":true}]' \ No newline at end of file + --data-raw $'[{"TypeId":4,"ProductId":11,"StageId":3,"OrderId":null,"RecordDate":"2025-10-28T08:19:20.418Z","PassedQuantityWeight":0.5,"PassedQuantity":5,"IssuedQuantityWeight":0.1,"IssuedQuantity":1,"ResponsibleUserId":12043,"Description":"","ProductName":"Th\xe9p 435","ProductCode":"SCM435","StockPassedQuantityWeight":0,"StockPassedQuantity":0,"StockIssuedQuantity":0,"StockIssuedQuantityWeight":0,"ReceiverUserId":12120,"ActionTypeId":1,"WareHouseId":1,"ProductStageId":3,"IsConfirm":true}]' + +#Get users +curl --request GET \ + --url https://dotnet.elidev.info:8157/ws/PortalUser/GetAllMemberUserShortInfo \ + --compressed \ + --header 'Accept: application/json, text/plain, */*' \ + --header 'Accept-Encoding: gzip, deflate, br, zstd' \ + --header 'Accept-Language: en-US,en;q=0.5' \ + --header 'AccessToken: 1k5fXyQVXtGkfjwS4g2USldGyLSA7Zwa2jdj5tLe+3YfSDlk02aYgqsFh5xArkdL4N529x7IYJOGrLJJgLBPNVgD51zFfEYBzfJmMH2RUm7iegvDJaMCLISySw0zd6kcsqeJi7vtuybgY2NDPxDgiSOj4wX417PzB8AVg5bl1ZAAJ3LcVAqqtA1PDTU5ZU1QQYapNeBNxAHjnd2ojTZK1GJBIyY5Gd8P9gB880ppAKq8manNMZYsa4d8tkYf0SJUul2aqLIWJAwDGORpPmfjqkN4hMh85xAfPTZi6m4DdI0u2rHDMLaZ8eIsV16qA8wimSDnWi0VeG0SZ4ugbCdJAi3t1/uICTftiy06PJEkulBLV+h2xS/7SlmEY2xoN5ISi++3FNqsFPGa9QH6akGu2C7IXEUBCg3iGJx0uL+vULmVqk5OJIXdqiKVQ366hvhPlK2AM1zbh49x/ngibe08483WTL5uAY/fsKuBxQCpTc2368Gqhpd7QRtZFKpzikhyTWsR3nQIi6ExSstCeFbe8ehgo0PuTPZNHH5IHTc49snH6IZrSbR+F62Wu/D+4DlvMTK/ktG6LVQ3r3jSJC5MAQDV5Q9WK3RvsWMPvZrsaVW/Exz0GBgWP4W0adADg7MFSlnGDOJm6I4fCLHZIJCUww50L6iNmzvrdibrQT5jKACVgNquMZCfeZlf3m2BwUx9T6J45lAePpJ+QaMh+2voFqRiOLi98MLqOG6TW7z96sadzFVR9YU1xwM51jQDjnUlrXt0+msq29Jqt8LoCyQsG4r3RgS/tUJhximq11MDXsSrJm6RubpKWl/MnF3QxcLTSwFE/SrGoGhnJnH5ILxZdyMN4PJjELeq3g8V5nEEm6lE/WNyvRMskel+Ods3XQIvE6o8KblUmFeM1rOIBkJbUVX7Ghaj0RNpvar86fF85BEozLBcED2XGkDANhGivuhqyrDpEOYjCwuC0eOjdj92fxlTTyo33ioR3xKcYFVlgMTlRX26sQDayf8hsPIBoDQtMHGKFfC2BJx4ujKTxtead8uz7c+CrlJuTbUkk+bp+wEUvKW5TvlX8HXKJWVLm05qZ7KmSLpsp35Iih2tZbBU+g==' \ + --header 'AppID: Minhthu2016' \ + --header 'Connection: keep-alive' \ + --header 'Origin: https://dotnet.elidev.info:8158' \ + --header 'Referer: https://dotnet.elidev.info:8158/' \ + --header 'Sec-Fetch-Dest: empty' \ + --header 'Sec-Fetch-Mode: cors' \ + --header 'Sec-Fetch-Site: same-site' \ + --header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0' \ No newline at end of file diff --git a/lib/features/products/presentation/pages/product_detail_page.dart b/lib/features/products/presentation/pages/product_detail_page.dart index dbfdc04..b745acc 100644 --- a/lib/features/products/presentation/pages/product_detail_page.dart +++ b/lib/features/products/presentation/pages/product_detail_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/di/providers.dart'; +import '../../../users/domain/entities/user_entity.dart'; import '../../domain/entities/product_stage_entity.dart'; /// Product detail page @@ -34,13 +35,20 @@ class _ProductDetailPageState extends ConsumerState { final TextEditingController _issuedQuantityController = TextEditingController(); final TextEditingController _issuedWeightController = TextEditingController(); + // Selected users for dropdowns + UserEntity? _selectedWarehouseUser; + UserEntity? _selectedEmployee; + @override void initState() { super.initState(); _providerKey = '${widget.warehouseId}_${widget.productId}'; - // Load product stages when page is initialized + // Load product stages and users when page is initialized Future.microtask(() async { + // Load users from Hive (no API call) + await ref.read(usersProvider.notifier).getUsers(); + await ref.read(productDetailProvider(_providerKey).notifier).loadProductDetail( widget.warehouseId, widget.productId, @@ -80,6 +88,10 @@ class _ProductDetailPageState extends ConsumerState { _passedWeightController.clear(); _issuedQuantityController.clear(); _issuedWeightController.clear(); + setState(() { + _selectedWarehouseUser = null; + _selectedEmployee = null; + }); } @override @@ -385,7 +397,6 @@ class _ProductDetailPageState extends ConsumerState { 'Passed Weight', '${stageToShow.passedQuantityWeight.toStringAsFixed(2)} kg', ), - const Divider(height: 24), _buildInfoRow('Issued Quantity', '${stageToShow.issuedQuantity}'), _buildInfoRow( 'Issued Weight', @@ -406,21 +417,18 @@ class _ProductDetailPageState extends ConsumerState { keyboardType: TextInputType.number, theme: theme, ), - const SizedBox(height: 12), _buildTextField( label: 'Passed Weight (kg)', controller: _passedWeightController, keyboardType: const TextInputType.numberWithOptions(decimal: true), theme: theme, ), - const Divider(height: 24), _buildTextField( label: 'Issued Quantity', controller: _issuedQuantityController, keyboardType: TextInputType.number, theme: theme, ), - const SizedBox(height: 12), _buildTextField( label: 'Issued Weight (kg)', controller: _issuedWeightController, @@ -430,9 +438,34 @@ class _ProductDetailPageState extends ConsumerState { ], ), - // Stage information - - + _buildSectionCard(theme: theme, title: "Nhân viên", icon: Icons.people, children: [ + // Warehouse User Dropdown + _buildUserDropdown( + label: 'Warehouse User', + value: _selectedWarehouseUser, + users: ref.watch(usersListProvider) + .where((user) => user.isWareHouseUser) + .toList(), + onChanged: (user) { + setState(() { + _selectedWarehouseUser = user; + }); + }, + theme: theme, + ), + // All Employees Dropdown + _buildUserDropdown( + label: 'Employee', + value: _selectedEmployee, + users: ref.watch(usersListProvider), + onChanged: (user) { + setState(() { + _selectedEmployee = user; + }); + }, + theme: theme, + ), + ]), // Add button SizedBox( @@ -489,6 +522,10 @@ class _ProductDetailPageState extends ConsumerState { // Log the values for debugging debugPrint('Adding new quantities for stage ${stage.productStageId}:'); + debugPrint(' Warehouse User: ${_selectedWarehouseUser?.fullName ?? "Not selected"}'); + debugPrint(' Warehouse User ID: ${_selectedWarehouseUser?.id}'); + debugPrint(' Employee: ${_selectedEmployee?.fullName ?? "Not selected"}'); + debugPrint(' Employee ID: ${_selectedEmployee?.id}'); debugPrint(' Passed Quantity: $passedQuantity'); debugPrint(' Passed Weight: $passedWeight'); debugPrint(' Issued Quantity: $issuedQuantity'); @@ -556,6 +593,7 @@ class _ProductDetailPageState extends ConsumerState { padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, children: [ Row( children: [ @@ -583,7 +621,7 @@ class _ProductDetailPageState extends ConsumerState { Widget _buildInfoRow(String label, String value) { return Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.only(bottom: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -699,6 +737,60 @@ class _ProductDetailPageState extends ConsumerState { ); } + Widget _buildUserDropdown({ + required String label, + required UserEntity? value, + required List users, + required Function(UserEntity?) onChanged, + required ThemeData theme, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + value: value, + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: theme.colorScheme.outline, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: theme.colorScheme.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + hint: Text('Select $label'), + items: users.map((user) { + return DropdownMenuItem( + value: user, + child: Text( + user.name.isNotEmpty ? '${user.name} ${user.firstName}' : user.email, + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: onChanged, + isExpanded: true, + ), + ], + ); + } + void _incrementValue(TextEditingController controller, double increment) { final currentValue = double.tryParse(controller.text) ?? 0.0; final newValue = currentValue + increment; diff --git a/lib/features/users/data/datasources/users_local_datasource.dart b/lib/features/users/data/datasources/users_local_datasource.dart new file mode 100644 index 0000000..1a0107e --- /dev/null +++ b/lib/features/users/data/datasources/users_local_datasource.dart @@ -0,0 +1,62 @@ +import 'package:hive_ce/hive.dart'; + +import '../models/user_model.dart'; + +/// Abstract interface for users local data source +abstract class UsersLocalDataSource { + /// Get all users from local storage + Future> getUsers(); + + /// Save users to local storage + Future saveUsers(List users); + + /// Clear all users from local storage + Future clearUsers(); +} + +/// Implementation of UsersLocalDataSource using Hive +class UsersLocalDataSourceImpl implements UsersLocalDataSource { + static const String _boxName = 'users'; + + Future> get _box async { + if (!Hive.isBoxOpen(_boxName)) { + return await Hive.openBox(_boxName); + } + return Hive.box(_boxName); + } + + @override + Future> getUsers() async { + try { + final box = await _box; + return box.values.toList(); + } catch (e) { + throw Exception('Failed to get users from local storage: $e'); + } + } + + @override + Future saveUsers(List users) async { + try { + final box = await _box; + await box.clear(); + + // Save users with their ID as key + for (final user in users) { + await box.put(user.id, user); + } + } catch (e) { + throw Exception('Failed to save users to local storage: $e'); + } + } + + @override + Future clearUsers() async { + try { + final box = await _box; + await box.clear(); + } catch (e) { + throw Exception('Failed to clear users from local storage: $e'); + } + } +} diff --git a/lib/features/users/data/datasources/users_remote_datasource.dart b/lib/features/users/data/datasources/users_remote_datasource.dart new file mode 100644 index 0000000..ea01cbf --- /dev/null +++ b/lib/features/users/data/datasources/users_remote_datasource.dart @@ -0,0 +1,57 @@ +import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/errors/exceptions.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/network/api_response.dart'; +import '../models/user_model.dart'; + +/// Abstract interface for users remote data source +abstract class UsersRemoteDataSource { + /// Fetch all users from the API + Future> getUsers(); +} + +/// Implementation of UsersRemoteDataSource using ApiClient +class UsersRemoteDataSourceImpl implements UsersRemoteDataSource { + final ApiClient apiClient; + + UsersRemoteDataSourceImpl(this.apiClient); + + @override + Future> getUsers() async { + try { + // Make API call to get all users + final response = await apiClient.get(ApiEndpoints.users); + + // Parse the API response using ApiResponse wrapper + final apiResponse = ApiResponse.fromJson( + response.data as Map, + (json) => (json as List) + .map((e) => UserModel.fromJson(e as Map)) + .toList(), + ); + + // Check if the API call was successful + if (apiResponse.isSuccess && apiResponse.value != null) { + return apiResponse.value!; + } else { + // Throw exception with error message from API + throw ServerException( + apiResponse.errors.isNotEmpty + ? apiResponse.errors.first + : 'Failed to get users', + ); + } + } catch (e) { + // Re-throw ServerException as-is + if (e is ServerException) { + rethrow; + } + // Re-throw NetworkException as-is + if (e is NetworkException) { + rethrow; + } + // Wrap other exceptions in ServerException + throw ServerException('Failed to get users: ${e.toString()}'); + } + } +} diff --git a/lib/features/users/data/models/user_model.dart b/lib/features/users/data/models/user_model.dart new file mode 100644 index 0000000..09344c7 --- /dev/null +++ b/lib/features/users/data/models/user_model.dart @@ -0,0 +1,163 @@ +import 'package:hive_ce/hive.dart'; + +import '../../domain/entities/user_entity.dart'; + +part 'user_model.g.dart'; + +@HiveType(typeId: 1) +class UserModel extends UserEntity { + @HiveField(0) + @override + final int id; + + @HiveField(1) + @override + final String firstName; + + @HiveField(2) + @override + final String name; + + @HiveField(3) + @override + final String? plateNumber; + + @HiveField(4) + @override + final String email; + + @HiveField(5) + @override + final String phone; + + @HiveField(6) + @override + final bool isParent; + + @HiveField(7) + @override + final String fullName; + + @HiveField(8) + @override + final String fullNameEmail; + + @HiveField(9) + @override + final String? referralCode; + + @HiveField(10) + @override + final String? avatar; + + @HiveField(11) + @override + final int departmentId; + + @HiveField(12) + @override + final bool isWareHouseUser; + + @HiveField(13) + @override + final int? wareHouseId; + + @HiveField(14) + @override + final int roleId; + + const UserModel({ + required this.id, + required this.firstName, + required this.name, + this.plateNumber, + required this.email, + required this.phone, + this.isParent = false, + required this.fullName, + required this.fullNameEmail, + this.referralCode, + this.avatar, + required this.departmentId, + this.isWareHouseUser = false, + this.wareHouseId, + required this.roleId, + }) : super( + id: id, + firstName: firstName, + name: name, + plateNumber: plateNumber, + email: email, + phone: phone, + isParent: isParent, + fullName: fullName, + fullNameEmail: fullNameEmail, + referralCode: referralCode, + avatar: avatar, + departmentId: departmentId, + isWareHouseUser: isWareHouseUser, + wareHouseId: wareHouseId, + roleId: roleId, + ); + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['Id'] ?? 0, + firstName: json['FirstName'] ?? '', + name: json['Name'] ?? '', + plateNumber: json['PlateNumber'], + email: json['Email'] ?? '', + phone: json['Phone'] ?? '', + isParent: json['IsParent'] ?? false, + fullName: json['FullName'] ?? '', + fullNameEmail: json['FullNameEmail'] ?? '', + referralCode: json['ReferralCode'], + avatar: json['Avatar'], + departmentId: json['DepartmentId'] ?? 0, + isWareHouseUser: json['IsWareHouseUser'] ?? false, + wareHouseId: json['WareHouseId'], + roleId: json['RoleId'] ?? 0, + ); + } + + Map toJson() { + return { + 'Id': id, + 'FirstName': firstName, + 'Name': name, + 'PlateNumber': plateNumber, + 'Email': email, + 'Phone': phone, + 'IsParent': isParent, + 'FullName': fullName, + 'FullNameEmail': fullNameEmail, + 'ReferralCode': referralCode, + 'Avatar': avatar, + 'DepartmentId': departmentId, + 'IsWareHouseUser': isWareHouseUser, + 'WareHouseId': wareHouseId, + 'RoleId': roleId, + }; + } + + UserEntity toEntity() { + return UserEntity( + id: id, + firstName: firstName, + name: name, + plateNumber: plateNumber, + email: email, + phone: phone, + isParent: isParent, + fullName: fullName, + fullNameEmail: fullNameEmail, + referralCode: referralCode, + avatar: avatar, + departmentId: departmentId, + isWareHouseUser: isWareHouseUser, + wareHouseId: wareHouseId, + roleId: roleId, + ); + } +} + diff --git a/lib/features/users/data/models/user_model.g.dart b/lib/features/users/data/models/user_model.g.dart new file mode 100644 index 0000000..e1d43d1 --- /dev/null +++ b/lib/features/users/data/models/user_model.g.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class UserModelAdapter extends TypeAdapter { + @override + final typeId = 1; + + @override + UserModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UserModel( + id: (fields[0] as num).toInt(), + firstName: fields[1] as String, + name: fields[2] as String, + plateNumber: fields[3] as String?, + email: fields[4] as String, + phone: fields[5] as String, + isParent: fields[6] == null ? false : fields[6] as bool, + fullName: fields[7] as String, + fullNameEmail: fields[8] as String, + referralCode: fields[9] as String?, + avatar: fields[10] as String?, + departmentId: (fields[11] as num).toInt(), + isWareHouseUser: fields[12] == null ? false : fields[12] as bool, + wareHouseId: (fields[13] as num?)?.toInt(), + roleId: (fields[14] as num).toInt(), + ); + } + + @override + void write(BinaryWriter writer, UserModel obj) { + writer + ..writeByte(15) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.firstName) + ..writeByte(2) + ..write(obj.name) + ..writeByte(3) + ..write(obj.plateNumber) + ..writeByte(4) + ..write(obj.email) + ..writeByte(5) + ..write(obj.phone) + ..writeByte(6) + ..write(obj.isParent) + ..writeByte(7) + ..write(obj.fullName) + ..writeByte(8) + ..write(obj.fullNameEmail) + ..writeByte(9) + ..write(obj.referralCode) + ..writeByte(10) + ..write(obj.avatar) + ..writeByte(11) + ..write(obj.departmentId) + ..writeByte(12) + ..write(obj.isWareHouseUser) + ..writeByte(13) + ..write(obj.wareHouseId) + ..writeByte(14) + ..write(obj.roleId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/features/users/data/repositories/users_repository_impl.dart b/lib/features/users/data/repositories/users_repository_impl.dart new file mode 100644 index 0000000..fe2ea4a --- /dev/null +++ b/lib/features/users/data/repositories/users_repository_impl.dart @@ -0,0 +1,67 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/errors/exceptions.dart'; +import '../../../../core/errors/failures.dart'; +import '../../domain/entities/user_entity.dart'; +import '../../domain/repositories/users_repository.dart'; +import '../datasources/users_local_datasource.dart'; +import '../datasources/users_remote_datasource.dart'; + +/// Implementation of UsersRepository +class UsersRepositoryImpl implements UsersRepository { + final UsersRemoteDataSource remoteDataSource; + final UsersLocalDataSource localDataSource; + + UsersRepositoryImpl({ + required this.remoteDataSource, + required this.localDataSource, + }); + + @override + Future>> getUsers() async { + try { + // Try to get users from local storage first + final localUsers = await localDataSource.getUsers(); + + if (localUsers.isNotEmpty) { + // Return local users if available + return Right(localUsers.map((model) => model.toEntity()).toList()); + } + + // If no local users, fetch from API + return await syncUsers(); + } catch (e) { + return Left(CacheFailure(e.toString())); + } + } + + @override + Future>> syncUsers() async { + try { + // Fetch users from API + final users = await remoteDataSource.getUsers(); + + // Save to local storage + await localDataSource.saveUsers(users); + + // Return as entities + return Right(users.map((model) => model.toEntity()).toList()); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message)); + } catch (e) { + return Left(ServerFailure(e.toString())); + } + } + + @override + Future> clearUsers() async { + try { + await localDataSource.clearUsers(); + return const Right(null); + } catch (e) { + return Left(CacheFailure(e.toString())); + } + } +} diff --git a/lib/features/users/domain/entities/user_entity.dart b/lib/features/users/domain/entities/user_entity.dart new file mode 100644 index 0000000..7b4fa0d --- /dev/null +++ b/lib/features/users/domain/entities/user_entity.dart @@ -0,0 +1,78 @@ +import 'package:equatable/equatable.dart'; + +/// User entity representing a user in the system +class UserEntity extends Equatable { + final int id; + final String firstName; + final String name; + final String? plateNumber; + final String email; + final String phone; + final bool isParent; + final String fullName; + final String fullNameEmail; + final String? referralCode; + final String? avatar; + final int departmentId; + final bool isWareHouseUser; + final int? wareHouseId; + final int roleId; + + const UserEntity({ + required this.id, + required this.firstName, + required this.name, + this.plateNumber, + required this.email, + required this.phone, + this.isParent = false, + required this.fullName, + required this.fullNameEmail, + this.referralCode, + this.avatar, + required this.departmentId, + this.isWareHouseUser = false, + this.wareHouseId, + required this.roleId, + }); + + @override + List get props => [ + id, + firstName, + name, + plateNumber, + email, + phone, + isParent, + fullName, + fullNameEmail, + referralCode, + avatar, + departmentId, + isWareHouseUser, + wareHouseId, + roleId, + ]; + + /// Get display name + String get displayName { + if (fullName.isNotEmpty) return fullName; + if (name.isNotEmpty) return name; + return email; + } + + /// Get initials from name (for avatar display) + String get initials { + if (firstName.isNotEmpty && name.isNotEmpty) { + return (firstName.substring(0, 1) + name.substring(0, 1)).toUpperCase(); + } + final parts = fullName.trim().split(' '); + if (parts.isEmpty) return '?'; + if (parts.length == 1) { + return parts[0].substring(0, 1).toUpperCase(); + } + return (parts[0].substring(0, 1) + parts[parts.length - 1].substring(0, 1)) + .toUpperCase(); + } +} diff --git a/lib/features/users/domain/repositories/users_repository.dart b/lib/features/users/domain/repositories/users_repository.dart new file mode 100644 index 0000000..4deaa17 --- /dev/null +++ b/lib/features/users/domain/repositories/users_repository.dart @@ -0,0 +1,16 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/errors/failures.dart'; +import '../entities/user_entity.dart'; + +/// Abstract repository interface for users +abstract class UsersRepository { + /// Get all users (from local storage if available, otherwise from API) + Future>> getUsers(); + + /// Sync users from API and save to local storage + Future>> syncUsers(); + + /// Clear all users from local storage + Future> clearUsers(); +} diff --git a/lib/features/users/domain/usecases/get_users_usecase.dart b/lib/features/users/domain/usecases/get_users_usecase.dart new file mode 100644 index 0000000..62709b1 --- /dev/null +++ b/lib/features/users/domain/usecases/get_users_usecase.dart @@ -0,0 +1,18 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/errors/failures.dart'; +import '../entities/user_entity.dart'; +import '../repositories/users_repository.dart'; + +/// Use case for getting users +/// Follows local-first strategy: returns local users if available, +/// otherwise syncs from API +class GetUsersUseCase { + final UsersRepository repository; + + GetUsersUseCase(this.repository); + + Future>> call() async { + return await repository.getUsers(); + } +} diff --git a/lib/features/users/domain/usecases/sync_users_usecase.dart b/lib/features/users/domain/usecases/sync_users_usecase.dart new file mode 100644 index 0000000..738c929 --- /dev/null +++ b/lib/features/users/domain/usecases/sync_users_usecase.dart @@ -0,0 +1,17 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/errors/failures.dart'; +import '../entities/user_entity.dart'; +import '../repositories/users_repository.dart'; + +/// Use case for syncing users from API +/// Forces refresh from API and saves to local storage +class SyncUsersUseCase { + final UsersRepository repository; + + SyncUsersUseCase(this.repository); + + Future>> call() async { + return await repository.syncUsers(); + } +} diff --git a/lib/features/users/presentation/providers/users_provider.dart b/lib/features/users/presentation/providers/users_provider.dart new file mode 100644 index 0000000..5ce2e1e --- /dev/null +++ b/lib/features/users/presentation/providers/users_provider.dart @@ -0,0 +1,54 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/usecases/get_users_usecase.dart'; +import '../../domain/usecases/sync_users_usecase.dart'; +import 'users_state.dart'; + +/// State notifier for users +class UsersNotifier extends StateNotifier { + final GetUsersUseCase getUsersUseCase; + final SyncUsersUseCase syncUsersUseCase; + + UsersNotifier({ + required this.getUsersUseCase, + required this.syncUsersUseCase, + }) : super(const UsersState()); + + /// Get users from local storage (or API if not cached) + Future getUsers() async { + state = state.copyWith(isLoading: true, error: null); + + final result = await getUsersUseCase(); + + result.fold( + (failure) => state = state.copyWith( + isLoading: false, + error: failure.message, + ), + (users) => state = state.copyWith( + users: users, + isLoading: false, + error: null, + ), + ); + } + + /// Sync users from API (force refresh) + Future syncUsers() async { + state = state.copyWith(isLoading: true, error: null); + + final result = await syncUsersUseCase(); + + result.fold( + (failure) => state = state.copyWith( + isLoading: false, + error: failure.message, + ), + (users) => state = state.copyWith( + users: users, + isLoading: false, + error: null, + ), + ); + } +} diff --git a/lib/features/users/presentation/providers/users_state.dart b/lib/features/users/presentation/providers/users_state.dart new file mode 100644 index 0000000..242ef8a --- /dev/null +++ b/lib/features/users/presentation/providers/users_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; + +import '../../domain/entities/user_entity.dart'; + +/// State for users feature +class UsersState extends Equatable { + final List users; + final bool isLoading; + final String? error; + + const UsersState({ + this.users = const [], + this.isLoading = false, + this.error, + }); + + UsersState copyWith({ + List? users, + bool? isLoading, + String? error, + }) { + return UsersState( + users: users ?? this.users, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } + + @override + List get props => [users, isLoading, error]; +} diff --git a/lib/features/warehouse/presentation/pages/warehouse_selection_page.dart b/lib/features/warehouse/presentation/pages/warehouse_selection_page.dart index 32f325c..d89e21c 100644 --- a/lib/features/warehouse/presentation/pages/warehouse_selection_page.dart +++ b/lib/features/warehouse/presentation/pages/warehouse_selection_page.dart @@ -26,9 +26,11 @@ class _WarehouseSelectionPageState @override void initState() { super.initState(); - // Load warehouses when page is first created + // Load warehouses and sync users when page is first created Future.microtask(() { ref.read(warehouseProvider.notifier).loadWarehouses(); + // Sync users from API and save to local storage + ref.read(usersProvider.notifier).syncUsers(); }); } diff --git a/lib/hive_registrar.g.dart b/lib/hive_registrar.g.dart new file mode 100644 index 0000000..b29bd8f --- /dev/null +++ b/lib/hive_registrar.g.dart @@ -0,0 +1,18 @@ +// Generated by Hive CE +// Do not modify +// Check in to version control + +import 'package:hive_ce/hive.dart'; +import 'package:minhthu/features/users/data/models/user_model.dart'; + +extension HiveRegistrar on HiveInterface { + void registerAdapters() { + registerAdapter(UserModelAdapter()); + } +} + +extension IsolatedHiveRegistrar on IsolatedHiveInterface { + void registerAdapters() { + registerAdapter(UserModelAdapter()); + } +} diff --git a/lib/main.dart b/lib/main.dart index af2a23f..c0331b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:path_provider/path_provider.dart'; import 'core/theme/app_theme.dart'; import 'core/router/app_router.dart'; +import 'features/users/data/models/user_model.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Initialize Hive + final appDocumentDir = await getApplicationDocumentsDirectory(); + await Hive.initFlutter(appDocumentDir.path); + + // Register Hive adapters + Hive.registerAdapter(UserModelAdapter()); + runApp( const ProviderScope( child: MyApp(), diff --git a/pubspec.lock b/pubspec.lock index 58e5cc6..264de72 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -633,7 +633,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" diff --git a/pubspec.yaml b/pubspec.yaml index 0fd7a58..927994d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: dartz: ^0.10.1 get_it: ^7.6.4 flutter_secure_storage: ^9.0.0 + path_provider: ^2.1.0 # Data Classes & Serialization freezed_annotation: ^2.4.1