From 0dda402246decc17d893b008e61259365a2e56e5 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Tue, 18 Nov 2025 17:04:00 +0700 Subject: [PATCH] update address --- CITY_WARD_IMPLEMENTATION.md | 59 + docs/address.sh | 28 + docs/auth.sh | 12 + lib/core/constants/storage_constants.dart | 57 +- lib/core/database/hive_service.dart | 10 + lib/core/database/models/enums.g.dart | 42 +- lib/core/router/app_router.dart | 17 +- .../address_remote_datasource.dart | 208 +++ .../location_local_datasource.dart | 137 ++ .../location_remote_datasource.dart | 96 ++ .../account/data/models/address_model.dart | 158 +++ .../account/data/models/address_model.g.dart | 74 ++ .../account/data/models/city_model.dart | 73 + .../account/data/models/city_model.g.dart | 47 + .../account/data/models/ward_model.dart | 73 + .../account/data/models/ward_model.g.dart | 47 + .../repositories/address_repository_impl.dart | 159 +++ .../location_repository_impl.dart | 96 ++ .../account/domain/entities/address.dart | 105 ++ .../account/domain/entities/city.dart | 27 + .../account/domain/entities/ward.dart | 27 + .../repositories/address_repository.dart | 38 + .../repositories/location_repository.dart | 21 + .../presentation/pages/address_form_page.dart | 1170 +++++++++++++++++ .../presentation/pages/addresses_page.dart | 356 +++-- .../providers/address_provider.dart | 221 ++++ .../providers/address_provider.g.dart | 290 ++++ .../providers/location_provider.dart | 153 +++ .../providers/location_provider.g.dart | 545 ++++++++ .../presentation/widgets/address_card.dart | 118 +- lib/hive_registrar.g.dart | 9 + pubspec.lock | 8 + pubspec.yaml | 1 + 33 files changed, 4250 insertions(+), 232 deletions(-) create mode 100644 CITY_WARD_IMPLEMENTATION.md create mode 100644 docs/address.sh create mode 100644 lib/features/account/data/datasources/address_remote_datasource.dart create mode 100644 lib/features/account/data/datasources/location_local_datasource.dart create mode 100644 lib/features/account/data/datasources/location_remote_datasource.dart create mode 100644 lib/features/account/data/models/address_model.dart create mode 100644 lib/features/account/data/models/address_model.g.dart create mode 100644 lib/features/account/data/models/city_model.dart create mode 100644 lib/features/account/data/models/city_model.g.dart create mode 100644 lib/features/account/data/models/ward_model.dart create mode 100644 lib/features/account/data/models/ward_model.g.dart create mode 100644 lib/features/account/data/repositories/address_repository_impl.dart create mode 100644 lib/features/account/data/repositories/location_repository_impl.dart create mode 100644 lib/features/account/domain/entities/address.dart create mode 100644 lib/features/account/domain/entities/city.dart create mode 100644 lib/features/account/domain/entities/ward.dart create mode 100644 lib/features/account/domain/repositories/address_repository.dart create mode 100644 lib/features/account/domain/repositories/location_repository.dart create mode 100644 lib/features/account/presentation/pages/address_form_page.dart create mode 100644 lib/features/account/presentation/providers/address_provider.dart create mode 100644 lib/features/account/presentation/providers/address_provider.g.dart create mode 100644 lib/features/account/presentation/providers/location_provider.dart create mode 100644 lib/features/account/presentation/providers/location_provider.g.dart diff --git a/CITY_WARD_IMPLEMENTATION.md b/CITY_WARD_IMPLEMENTATION.md new file mode 100644 index 0000000..b87e035 --- /dev/null +++ b/CITY_WARD_IMPLEMENTATION.md @@ -0,0 +1,59 @@ +# City and Ward API Implementation - Complete Guide + +## Files Created ✅ + +1. ✅ `lib/features/account/domain/entities/city.dart` +2. ✅ `lib/features/account/domain/entities/ward.dart` +3. ✅ `lib/features/account/data/models/city_model.dart` +4. ✅ `lib/features/account/data/models/ward_model.dart` +5. ✅ Updated `lib/core/constants/storage_constants.dart` + - Added `cityBox` and `wardBox` + - Added `cityModel = 31` and `wardModel = 32` + - Shifted all enum IDs by +2 + +## Implementation Status + +### Completed: +- ✅ Domain entities (City, Ward) +- ✅ Hive models with type adapters +- ✅ Storage constants updated +- ✅ Build runner generated .g.dart files + +### Remaining (Need to implement): + +1. **Remote Datasource** - `lib/features/account/data/datasources/location_remote_datasource.dart` +2. **Local Datasource** - `lib/features/account/data/datasources/location_local_datasource.dart` +3. **Repository Interface** - `lib/features/account/domain/repositories/location_repository.dart` +4. **Repository Implementation** - `lib/features/account/data/repositories/location_repository_impl.dart` +5. **Providers** - `lib/features/account/presentation/providers/location_provider.dart` +6. **Update AddressFormPage** to use the providers + +## API Endpoints (from docs/auth.sh) + +### Get Cities: +```bash +POST /api/method/frappe.client.get_list +Body: { + "doctype": "City", + "fields": ["city_name","name","code"], + "limit_page_length": 0 +} +``` + +### Get Wards (filtered by city): +```bash +POST /api/method/frappe.client.get_list +Body: { + "doctype": "Ward", + "fields": ["ward_name","name","code"], + "filters": {"city": "96"}, + "limit_page_length": 0 +} +``` + +## Offline-First Strategy + +1. **Cities**: Cache in Hive, refresh from API periodically +2. **Wards**: Load from API when city selected, cache per city + +Would you like me to generate the remaining implementation files now? diff --git a/docs/address.sh b/docs/address.sh new file mode 100644 index 0000000..7a29b59 --- /dev/null +++ b/docs/address.sh @@ -0,0 +1,28 @@ +#get list address +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.address.get_list' \ +--header 'Cookie: sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; full_name=phuoc; sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; system_user=no; user_id=vodanh.2901%40gmail.com; user_image=https%3A//secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8%3Fd%3D404%26s%3D200' \ +--header 'X-Frappe-Csrf-Token: a22fa53eeaa923f71f2fd879d2863a0985a6f2107f5f7f66d34cd62d' \ +--header 'Content-Type: application/json' \ +--data '{ + "limit_start" : 0, + "limit_page_length": 0, + "is_default" : false +}' + +#update/insert address +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.address.update' \ +--header 'Cookie: sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; full_name=phuoc; sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; system_user=no; user_id=vodanh.2901%40gmail.com; user_image=https%3A//secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8%3Fd%3D404%26s%3D200' \ +--header 'X-Frappe-Csrf-Token: a22fa53eeaa923f71f2fd879d2863a0985a6f2107f5f7f66d34cd62d' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "name": "Công ty Tiến Nguyễn-Billing", // bỏ trống hoặc không truyền để thêm mới + "address_title": "Công ty Tiến Nguyễn", + "address_line1": "Khu 2, Hoàng Cương, Thanh Ba, Phú Thọ", + "phone": "0911111111", + "email": "address75675@gmail.com", + "fax": null, + "tax_code": "12312", + "city_code": "96", + "ward_code": "32248", + "is_default": false +}' \ No newline at end of file diff --git a/docs/auth.sh b/docs/auth.sh index 5493b7c..e5d2425 100644 --- a/docs/auth.sh +++ b/docs/auth.sh @@ -25,6 +25,18 @@ curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \ "limit_page_length": 0 }' +GET WARD +curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \ +--header 'X-Frappe-Csrf-Token: a22fa53eeaa923f71f2fd879d2863a0985a6f2107f5f7f66d34cd62d' \ +--header 'Cookie: sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; full_name=phuoc; sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; system_user=no; user_id=vodanh.2901%40gmail.com; user_image=https%3A//secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8%3Fd%3D404%26s%3D200' \ +--header 'Content-Type: application/json' \ +--data '{ + "doctype": "Ward", + "fields": ["ward_name","name","code"], + "filters": {"city": "96"}, + "limit_page_length": 0 +}' + GET ROLE curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \ --header 'X-Frappe-Csrf-Token: 3d072ea39d245c2340ecc42d0825f6cbb7e674943c9c74346c8e4629' \ diff --git a/lib/core/constants/storage_constants.dart b/lib/core/constants/storage_constants.dart index e052c29..c8a4b7a 100644 --- a/lib/core/constants/storage_constants.dart +++ b/lib/core/constants/storage_constants.dart @@ -57,6 +57,10 @@ class HiveBoxNames { /// Offline request queue for failed API calls static const String offlineQueueBox = 'offline_queue_box'; + /// City and Ward boxes for location data + static const String cityBox = 'city_box'; + static const String wardBox = 'ward_box'; + /// Get all box names for initialization static List get allBoxes => [ userBox, @@ -67,6 +71,8 @@ class HiveBoxNames { quotes, loyaltyBox, rewardsBox, + cityBox, + wardBox, settingsBox, cacheBox, syncStateBox, @@ -114,7 +120,7 @@ class HiveTypeIds { static const int chatRoomModel = 18; static const int messageModel = 19; - // Extended Models (20-29) + // Extended Models (20-30) static const int notificationModel = 20; static const int showroomModel = 21; static const int showroomProductModel = 22; @@ -125,30 +131,33 @@ class HiveTypeIds { static const int categoryModel = 27; static const int favoriteModel = 28; static const int businessUnitModel = 29; + static const int addressModel = 30; + static const int cityModel = 31; + static const int wardModel = 32; - // Enums (30-59) - static const int userRole = 30; - static const int userStatus = 31; - static const int loyaltyTier = 32; - static const int orderStatus = 33; - static const int invoiceType = 34; - static const int invoiceStatus = 35; - static const int paymentMethod = 36; - static const int paymentStatus = 37; - static const int entryType = 38; - static const int entrySource = 39; - static const int complaintStatus = 40; - static const int giftCategory = 41; - static const int giftStatus = 42; - static const int pointsStatus = 43; - static const int projectType = 44; - static const int submissionStatus = 45; - static const int designStatus = 46; - static const int quoteStatus = 47; - static const int roomType = 48; - static const int contentType = 49; - static const int reminderType = 50; - static const int notificationType = 51; + // Enums (33-62) + static const int userRole = 33; + static const int userStatus = 34; + static const int loyaltyTier = 35; + static const int orderStatus = 36; + static const int invoiceType = 37; + static const int invoiceStatus = 38; + static const int paymentMethod = 39; + static const int paymentStatus = 40; + static const int entryType = 41; + static const int entrySource = 42; + static const int complaintStatus = 43; + static const int giftCategory = 44; + static const int giftStatus = 45; + static const int pointsStatus = 46; + static const int projectType = 47; + static const int submissionStatus = 48; + static const int designStatus = 49; + static const int quoteStatus = 50; + static const int roomType = 51; + static const int contentType = 52; + static const int reminderType = 53; + static const int notificationType = 54; // Aliases for backward compatibility and clarity static const int memberTier = loyaltyTier; // Alias for loyaltyTier diff --git a/lib/core/database/hive_service.dart b/lib/core/database/hive_service.dart index 5c4f61d..bbffac0 100644 --- a/lib/core/database/hive_service.dart +++ b/lib/core/database/hive_service.dart @@ -129,6 +129,12 @@ class HiveService { debugPrint( 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userModel) ? "✓" : "✗"} UserModel adapter', ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cityModel) ? "✓" : "✗"} CityModel adapter', + ); + debugPrint( + 'HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.wardModel) ? "✓" : "✗"} WardModel adapter', + ); debugPrint('HiveService: Type adapters registered successfully'); } @@ -158,6 +164,10 @@ class HiveService { // Favorite products box (non-sensitive) - caches Product entities from wishlist API Hive.openBox(HiveBoxNames.favoriteProductsBox), + + // Location boxes (non-sensitive) - caches cities and wards for address forms + Hive.openBox(HiveBoxNames.cityBox), + Hive.openBox(HiveBoxNames.wardBox), ]); // Open potentially encrypted boxes (sensitive data) diff --git a/lib/core/database/models/enums.g.dart b/lib/core/database/models/enums.g.dart index 7d85e85..6b8b84e 100644 --- a/lib/core/database/models/enums.g.dart +++ b/lib/core/database/models/enums.g.dart @@ -8,7 +8,7 @@ part of 'enums.dart'; class UserRoleAdapter extends TypeAdapter { @override - final typeId = 30; + final typeId = 33; @override UserRole read(BinaryReader reader) { @@ -53,7 +53,7 @@ class UserRoleAdapter extends TypeAdapter { class UserStatusAdapter extends TypeAdapter { @override - final typeId = 31; + final typeId = 34; @override UserStatus read(BinaryReader reader) { @@ -98,7 +98,7 @@ class UserStatusAdapter extends TypeAdapter { class LoyaltyTierAdapter extends TypeAdapter { @override - final typeId = 32; + final typeId = 35; @override LoyaltyTier read(BinaryReader reader) { @@ -151,7 +151,7 @@ class LoyaltyTierAdapter extends TypeAdapter { class OrderStatusAdapter extends TypeAdapter { @override - final typeId = 33; + final typeId = 36; @override OrderStatus read(BinaryReader reader) { @@ -216,7 +216,7 @@ class OrderStatusAdapter extends TypeAdapter { class InvoiceTypeAdapter extends TypeAdapter { @override - final typeId = 34; + final typeId = 37; @override InvoiceType read(BinaryReader reader) { @@ -261,7 +261,7 @@ class InvoiceTypeAdapter extends TypeAdapter { class InvoiceStatusAdapter extends TypeAdapter { @override - final typeId = 35; + final typeId = 38; @override InvoiceStatus read(BinaryReader reader) { @@ -318,7 +318,7 @@ class InvoiceStatusAdapter extends TypeAdapter { class PaymentMethodAdapter extends TypeAdapter { @override - final typeId = 36; + final typeId = 39; @override PaymentMethod read(BinaryReader reader) { @@ -375,7 +375,7 @@ class PaymentMethodAdapter extends TypeAdapter { class PaymentStatusAdapter extends TypeAdapter { @override - final typeId = 37; + final typeId = 40; @override PaymentStatus read(BinaryReader reader) { @@ -428,7 +428,7 @@ class PaymentStatusAdapter extends TypeAdapter { class EntryTypeAdapter extends TypeAdapter { @override - final typeId = 38; + final typeId = 41; @override EntryType read(BinaryReader reader) { @@ -477,7 +477,7 @@ class EntryTypeAdapter extends TypeAdapter { class EntrySourceAdapter extends TypeAdapter { @override - final typeId = 39; + final typeId = 42; @override EntrySource read(BinaryReader reader) { @@ -538,7 +538,7 @@ class EntrySourceAdapter extends TypeAdapter { class ComplaintStatusAdapter extends TypeAdapter { @override - final typeId = 40; + final typeId = 43; @override ComplaintStatus read(BinaryReader reader) { @@ -587,7 +587,7 @@ class ComplaintStatusAdapter extends TypeAdapter { class GiftCategoryAdapter extends TypeAdapter { @override - final typeId = 41; + final typeId = 44; @override GiftCategory read(BinaryReader reader) { @@ -636,7 +636,7 @@ class GiftCategoryAdapter extends TypeAdapter { class GiftStatusAdapter extends TypeAdapter { @override - final typeId = 42; + final typeId = 45; @override GiftStatus read(BinaryReader reader) { @@ -681,7 +681,7 @@ class GiftStatusAdapter extends TypeAdapter { class PointsStatusAdapter extends TypeAdapter { @override - final typeId = 43; + final typeId = 46; @override PointsStatus read(BinaryReader reader) { @@ -722,7 +722,7 @@ class PointsStatusAdapter extends TypeAdapter { class ProjectTypeAdapter extends TypeAdapter { @override - final typeId = 44; + final typeId = 47; @override ProjectType read(BinaryReader reader) { @@ -779,7 +779,7 @@ class ProjectTypeAdapter extends TypeAdapter { class SubmissionStatusAdapter extends TypeAdapter { @override - final typeId = 45; + final typeId = 48; @override SubmissionStatus read(BinaryReader reader) { @@ -828,7 +828,7 @@ class SubmissionStatusAdapter extends TypeAdapter { class DesignStatusAdapter extends TypeAdapter { @override - final typeId = 46; + final typeId = 49; @override DesignStatus read(BinaryReader reader) { @@ -885,7 +885,7 @@ class DesignStatusAdapter extends TypeAdapter { class QuoteStatusAdapter extends TypeAdapter { @override - final typeId = 47; + final typeId = 50; @override QuoteStatus read(BinaryReader reader) { @@ -946,7 +946,7 @@ class QuoteStatusAdapter extends TypeAdapter { class RoomTypeAdapter extends TypeAdapter { @override - final typeId = 48; + final typeId = 51; @override RoomType read(BinaryReader reader) { @@ -995,7 +995,7 @@ class RoomTypeAdapter extends TypeAdapter { class ContentTypeAdapter extends TypeAdapter { @override - final typeId = 49; + final typeId = 52; @override ContentType read(BinaryReader reader) { @@ -1056,7 +1056,7 @@ class ContentTypeAdapter extends TypeAdapter { class ReminderTypeAdapter extends TypeAdapter { @override - final typeId = 50; + final typeId = 53; @override ReminderType read(BinaryReader reader) { diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index ecf9936..1029944 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -8,10 +8,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:worker/features/account/domain/entities/address.dart'; +import 'package:worker/features/account/presentation/pages/address_form_page.dart'; import 'package:worker/features/account/presentation/pages/addresses_page.dart'; -import 'package:worker/features/auth/presentation/providers/auth_provider.dart'; import 'package:worker/features/account/presentation/pages/change_password_page.dart'; import 'package:worker/features/account/presentation/pages/profile_edit_page.dart'; +import 'package:worker/features/auth/presentation/providers/auth_provider.dart'; import 'package:worker/features/auth/domain/entities/business_unit.dart'; import 'package:worker/features/auth/presentation/pages/business_unit_selection_page.dart'; import 'package:worker/features/auth/presentation/pages/forgot_password_page.dart'; @@ -369,6 +371,19 @@ final routerProvider = Provider((ref) { MaterialPage(key: state.pageKey, child: const AddressesPage()), ), + // Address Form Route (Create/Edit) + GoRoute( + path: RouteNames.addressForm, + name: RouteNames.addressForm, + pageBuilder: (context, state) { + final address = state.extra as Address?; + return MaterialPage( + key: state.pageKey, + child: AddressFormPage(address: address), + ); + }, + ), + // Change Password Route GoRoute( path: RouteNames.changePassword, diff --git a/lib/features/account/data/datasources/address_remote_datasource.dart b/lib/features/account/data/datasources/address_remote_datasource.dart new file mode 100644 index 0000000..a0b13fc --- /dev/null +++ b/lib/features/account/data/datasources/address_remote_datasource.dart @@ -0,0 +1,208 @@ +/// Address Remote Data Source +/// +/// Handles API calls to Frappe ERPNext address endpoints. +library; + +import 'package:dio/dio.dart'; +import 'package:worker/core/errors/exceptions.dart'; +import 'package:worker/features/account/data/models/address_model.dart'; + +/// Address Remote Data Source +/// +/// Provides methods to interact with address API endpoints. +/// Online-only approach - no offline caching. +class AddressRemoteDataSource { + final Dio _dio; + + AddressRemoteDataSource(this._dio); + + /// Get list of addresses + /// + /// Fetches all addresses for the authenticated user. + /// Optionally filter by default address. + /// + /// API: GET /api/method/building_material.building_material.api.address.get_list + Future> getAddresses({ + int limitStart = 0, + int limitPageLength = 0, + bool? isDefault, + }) async { + try { + _debugPrint('Fetching addresses list...'); + + final response = await _dio.post( + '/api/method/building_material.building_material.api.address.get_list', + data: { + 'limit_start': limitStart, + 'limit_page_length': limitPageLength, + if (isDefault != null) 'is_default': isDefault, + }, + ); + + if (response.statusCode == 200) { + final data = response.data; + _debugPrint('Response data: $data'); + + // Extract addresses from response + if (data is Map && data.containsKey('message')) { + final message = data['message']; + _debugPrint('Message type: ${message.runtimeType}'); + + // Handle array response + if (message is List) { + _debugPrint('Parsing ${message.length} addresses from list'); + final addresses = []; + for (var i = 0; i < message.length; i++) { + try { + final item = message[i] as Map; + _debugPrint('Parsing address $i: $item'); + final address = AddressModel.fromJson(item); + addresses.add(address); + } catch (e) { + _debugPrint('Error parsing address $i: $e'); + rethrow; + } + } + + _debugPrint('Fetched ${addresses.length} addresses'); + return addresses; + } + + // Handle object with data field + if (message is Map && message.containsKey('data')) { + final dataList = message['data'] as List; + _debugPrint('Parsing ${dataList.length} addresses from data field'); + final addresses = []; + for (var i = 0; i < dataList.length; i++) { + try { + final item = dataList[i] as Map; + _debugPrint('Parsing address $i: $item'); + final address = AddressModel.fromJson(item); + addresses.add(address); + } catch (e) { + _debugPrint('Error parsing address $i: $e'); + rethrow; + } + } + + _debugPrint('Fetched ${addresses.length} addresses'); + return addresses; + } + } + + throw const ServerException('Invalid response format'); + } else { + throw ServerException( + 'Failed to fetch addresses: ${response.statusCode}', + ); + } + } catch (e) { + _debugPrint('Error fetching addresses: $e'); + rethrow; + } + } + + /// Create or update address + /// + /// If name is provided (not empty), updates existing address. + /// If name is null/empty, creates new address. + /// + /// Per API docs: When name field is null/empty, the API creates a new address. + /// When name has a value, the API updates the existing address. + /// + /// API: POST /api/method/building_material.building_material.api.address.update + Future saveAddress(AddressModel address) async { + try { + final isUpdate = address.name.isNotEmpty; + _debugPrint( + isUpdate + ? 'Updating address: ${address.name}' + : 'Creating new address', + ); + + // toJson() already handles setting name to null for creation + final data = address.toJson(); + _debugPrint('Request data: $data'); + + final response = await _dio.post( + '/api/method/building_material.building_material.api.address.update', + data: data, + ); + + if (response.statusCode == 200) { + final data = response.data; + _debugPrint('Response data: $data'); + + // Check for API error response (even with 200 status) + if (data is Map && data.containsKey('message')) { + final message = data['message']; + + // Check for error response format + if (message is Map && message.containsKey('error')) { + final error = message['error'] as String; + _debugPrint('API error: $error'); + throw ServerException(error); + } + + // Handle direct address object + if (message is Map) { + final savedAddress = AddressModel.fromJson(message); + _debugPrint('Address saved: ${savedAddress.name}'); + return savedAddress; + } + + // Handle nested data + if (message is Map && message.containsKey('data')) { + final savedAddress = + AddressModel.fromJson(message['data'] as Map); + _debugPrint('Address saved: ${savedAddress.name}'); + return savedAddress; + } + } + + throw const ServerException('Invalid response format'); + } else { + throw ServerException( + 'Failed to save address: ${response.statusCode}', + ); + } + } catch (e) { + _debugPrint('Error saving address: $e'); + rethrow; + } + } + + /// Delete address + /// + /// Note: API endpoint for delete not provided in docs. + /// This is a placeholder - adjust when endpoint is available. + Future deleteAddress(String name) async { + try { + _debugPrint('Deleting address: $name'); + + // TODO: Update with actual delete endpoint when available + final response = await _dio.post( + '/api/method/building_material.building_material.api.address.delete', + data: {'name': name}, + ); + + if (response.statusCode == 200) { + _debugPrint('Address deleted: $name'); + return; + } else { + throw ServerException( + 'Failed to delete address: ${response.statusCode}', + ); + } + } catch (e) { + _debugPrint('Error deleting address: $e'); + rethrow; + } + } + + /// Debug print helper + void _debugPrint(String message) { + // ignore: avoid_print + print('[AddressRemoteDataSource] $message'); + } +} diff --git a/lib/features/account/data/datasources/location_local_datasource.dart b/lib/features/account/data/datasources/location_local_datasource.dart new file mode 100644 index 0000000..f009817 --- /dev/null +++ b/lib/features/account/data/datasources/location_local_datasource.dart @@ -0,0 +1,137 @@ +/// Location Local Data Source +/// +/// Handles Hive caching for cities and wards. +library; + +import 'package:hive_ce/hive.dart'; +import 'package:worker/core/constants/storage_constants.dart'; +import 'package:worker/core/database/hive_service.dart'; +import 'package:worker/features/account/data/models/city_model.dart'; +import 'package:worker/features/account/data/models/ward_model.dart'; + +/// Location Local Data Source +/// +/// Provides offline-first caching for cities and wards using Hive. +class LocationLocalDataSource { + final HiveService _hiveService; + + LocationLocalDataSource(this._hiveService); + + // ============================================================================ + // CITIES + // ============================================================================ + + /// Get city box + Box get _cityBox => _hiveService.getBox(HiveBoxNames.cityBox); + + /// Get all cached cities + List getCities() { + try { + final cities = _cityBox.values.whereType().toList(); + return cities; + } catch (e) { + return []; + } + } + + /// Save cities to cache + Future saveCities(List cities) async { + try { + // Only clear if there are existing cities + if (_cityBox.isNotEmpty) { + await _cityBox.clear(); + } + + for (final city in cities) { + await _cityBox.put(city.code, city); + } + } catch (e) { + rethrow; + } + } + + /// Get city by code + CityModel? getCityByCode(String code) { + try { + return _cityBox.get(code) as CityModel?; + } catch (e) { + return null; + } + } + + /// Check if cities are cached + bool hasCities() { + return _cityBox.isNotEmpty; + } + + // ============================================================================ + // WARDS + // ============================================================================ + + /// Get ward box + Box get _wardBox => _hiveService.getBox(HiveBoxNames.wardBox); + + /// Get cached wards for a city + /// + /// Wards are stored with key: "cityCode_wardCode" + List getWards(String cityCode) { + try { + final wards = _wardBox.values + .whereType() + .where((ward) { + // Check if this ward belongs to the city + final key = '${cityCode}_${ward.code}'; + return _wardBox.containsKey(key); + }) + .toList(); + + return wards; + } catch (e) { + return []; + } + } + + /// Save wards for a specific city to cache + Future saveWards(String cityCode, List wards) async { + try { + // Remove old wards for this city (only if they exist) + final keysToDelete = _wardBox.keys + .where((key) => key.toString().startsWith('${cityCode}_')) + .toList(); + + if (keysToDelete.isNotEmpty) { + for (final key in keysToDelete) { + await _wardBox.delete(key); + } + } + + // Save new wards + for (final ward in wards) { + final key = '${cityCode}_${ward.code}'; + await _wardBox.put(key, ward); + } + } catch (e) { + rethrow; + } + } + + /// Check if wards are cached for a city + bool hasWards(String cityCode) { + return _wardBox.keys.any((key) => key.toString().startsWith('${cityCode}_')); + } + + /// Clear all cached data + Future clearAll() async { + try { + // Only clear if boxes are not empty + if (_cityBox.isNotEmpty) { + await _cityBox.clear(); + } + if (_wardBox.isNotEmpty) { + await _wardBox.clear(); + } + } catch (e) { + rethrow; + } + } +} diff --git a/lib/features/account/data/datasources/location_remote_datasource.dart b/lib/features/account/data/datasources/location_remote_datasource.dart new file mode 100644 index 0000000..a28ea6a --- /dev/null +++ b/lib/features/account/data/datasources/location_remote_datasource.dart @@ -0,0 +1,96 @@ +/// Location Remote Data Source +/// +/// Handles API calls for cities and wards using Frappe ERPNext client.get_list. +library; + +import 'package:dio/dio.dart'; +import 'package:worker/core/errors/exceptions.dart'; +import 'package:worker/features/account/data/models/city_model.dart'; +import 'package:worker/features/account/data/models/ward_model.dart'; + +/// Location Remote Data Source +/// +/// Provides methods to fetch cities and wards from API. +class LocationRemoteDataSource { + final Dio _dio; + + LocationRemoteDataSource(this._dio); + + /// Get all cities + /// + /// API: POST /api/method/frappe.client.get_list + Future> getCities() async { + try { + final response = await _dio.post( + '/api/method/frappe.client.get_list', + data: { + 'doctype': 'City', + 'fields': ['city_name', 'name', 'code'], + 'limit_page_length': 0, + }, + ); + + if (response.statusCode == 200) { + final data = response.data; + + if (data is Map && data.containsKey('message')) { + final message = data['message']; + + if (message is List) { + final cities = message + .map((item) => CityModel.fromJson(item as Map)) + .toList(); + + return cities; + } + } + + throw const ServerException('Invalid response format'); + } else { + throw ServerException('Failed to fetch cities: ${response.statusCode}'); + } + } catch (e) { + rethrow; + } + } + + /// Get wards for a specific city + /// + /// API: POST /api/method/frappe.client.get_list + /// [cityCode] - The city code to filter wards + Future> getWards(String cityCode) async { + try { + final response = await _dio.post( + '/api/method/frappe.client.get_list', + data: { + 'doctype': 'Ward', + 'fields': ['ward_name', 'name', 'code'], + 'filters': {'city': cityCode}, + 'limit_page_length': 0, + }, + ); + + if (response.statusCode == 200) { + final data = response.data; + + if (data is Map && data.containsKey('message')) { + final message = data['message']; + + if (message is List) { + final wards = message + .map((item) => WardModel.fromJson(item as Map)) + .toList(); + + return wards; + } + } + + throw const ServerException('Invalid response format'); + } else { + throw ServerException('Failed to fetch wards: ${response.statusCode}'); + } + } catch (e) { + rethrow; + } + } +} diff --git a/lib/features/account/data/models/address_model.dart b/lib/features/account/data/models/address_model.dart new file mode 100644 index 0000000..5804d6a --- /dev/null +++ b/lib/features/account/data/models/address_model.dart @@ -0,0 +1,158 @@ +/// Address Model +/// +/// Hive model for caching address data from ERPNext API. +library; + +import 'package:hive_ce/hive.dart'; +import 'package:worker/core/constants/storage_constants.dart'; +import 'package:worker/features/account/domain/entities/address.dart'; + +part 'address_model.g.dart'; + +/// Address Model +/// +/// Hive model for storing address data with ERPNext API compatibility. +@HiveType(typeId: HiveTypeIds.addressModel) +class AddressModel extends HiveObject { + /// Address name (ID in ERPNext) + @HiveField(0) + String name; + + /// Display title for the address + @HiveField(1) + String addressTitle; + + /// Address line 1 (street, number, etc.) + @HiveField(2) + String addressLine1; + + /// Phone number + @HiveField(3) + String phone; + + /// Email address + @HiveField(4) + String? email; + + /// Fax number (optional) + @HiveField(5) + String? fax; + + /// Tax code/ID + @HiveField(6) + String? taxCode; + + /// City code (from ERPNext location master) + @HiveField(7) + String cityCode; + + /// Ward code (from ERPNext location master) + @HiveField(8) + String wardCode; + + /// Whether this is the default address + @HiveField(9) + bool isDefault; + + /// City name (for display) + @HiveField(10) + String? cityName; + + /// Ward name (for display) + @HiveField(11) + String? wardName; + + AddressModel({ + required this.name, + required this.addressTitle, + required this.addressLine1, + required this.phone, + this.email, + this.fax, + this.taxCode, + required this.cityCode, + required this.wardCode, + this.isDefault = false, + this.cityName, + this.wardName, + }); + + /// Create from JSON (API response) + factory AddressModel.fromJson(Map json) { + return AddressModel( + name: json['name'] as String? ?? '', + addressTitle: json['address_title'] as String? ?? '', + addressLine1: json['address_line1'] as String? ?? '', + phone: json['phone'] as String? ?? '', + email: json['email'] as String?, + fax: json['fax'] as String?, + taxCode: json['tax_code'] as String?, + cityCode: json['city_code'] as String? ?? '', + wardCode: json['ward_code'] as String? ?? '', + isDefault: json['is_default'] == 1 || json['is_default'] == true, + cityName: json['city_name'] as String?, + wardName: json['ward_name'] as String?, + ); + } + + /// Convert to JSON (API request) + Map toJson() { + return { + // If name is empty, send null to indicate new address creation + 'name': name.isEmpty ? null : name, + 'address_title': addressTitle, + 'address_line1': addressLine1, + 'phone': phone, + if (email != null && email!.isNotEmpty) 'email': email, + if (fax != null && fax!.isNotEmpty) 'fax': fax, + if (taxCode != null && taxCode!.isNotEmpty) 'tax_code': taxCode, + 'city_code': cityCode, + 'ward_code': wardCode, + 'is_default': isDefault, + if (cityName != null && cityName!.isNotEmpty) 'city_name': cityName, + if (wardName != null && wardName!.isNotEmpty) 'ward_name': wardName, + }; + } + + /// Convert to domain entity + Address toEntity() { + return Address( + name: name, + addressTitle: addressTitle, + addressLine1: addressLine1, + phone: phone, + email: email, + fax: fax, + taxCode: taxCode, + cityCode: cityCode, + wardCode: wardCode, + isDefault: isDefault, + cityName: cityName, + wardName: wardName, + ); + } + + /// Create from domain entity + factory AddressModel.fromEntity(Address entity) { + return AddressModel( + name: entity.name, + addressTitle: entity.addressTitle, + addressLine1: entity.addressLine1, + phone: entity.phone, + email: entity.email, + fax: entity.fax, + taxCode: entity.taxCode, + cityCode: entity.cityCode, + wardCode: entity.wardCode, + isDefault: entity.isDefault, + cityName: entity.cityName, + wardName: entity.wardName, + ); + } + + @override + String toString() { + return 'AddressModel(name: $name, addressTitle: $addressTitle, ' + 'addressLine1: $addressLine1, phone: $phone, isDefault: $isDefault)'; + } +} diff --git a/lib/features/account/data/models/address_model.g.dart b/lib/features/account/data/models/address_model.g.dart new file mode 100644 index 0000000..7658ee8 --- /dev/null +++ b/lib/features/account/data/models/address_model.g.dart @@ -0,0 +1,74 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'address_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class AddressModelAdapter extends TypeAdapter { + @override + final typeId = 30; + + @override + AddressModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AddressModel( + name: fields[0] as String, + addressTitle: fields[1] as String, + addressLine1: fields[2] as String, + phone: fields[3] as String, + email: fields[4] as String?, + fax: fields[5] as String?, + taxCode: fields[6] as String?, + cityCode: fields[7] as String, + wardCode: fields[8] as String, + isDefault: fields[9] == null ? false : fields[9] as bool, + cityName: fields[10] as String?, + wardName: fields[11] as String?, + ); + } + + @override + void write(BinaryWriter writer, AddressModel obj) { + writer + ..writeByte(12) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.addressTitle) + ..writeByte(2) + ..write(obj.addressLine1) + ..writeByte(3) + ..write(obj.phone) + ..writeByte(4) + ..write(obj.email) + ..writeByte(5) + ..write(obj.fax) + ..writeByte(6) + ..write(obj.taxCode) + ..writeByte(7) + ..write(obj.cityCode) + ..writeByte(8) + ..write(obj.wardCode) + ..writeByte(9) + ..write(obj.isDefault) + ..writeByte(10) + ..write(obj.cityName) + ..writeByte(11) + ..write(obj.wardName); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AddressModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/features/account/data/models/city_model.dart b/lib/features/account/data/models/city_model.dart new file mode 100644 index 0000000..975f2cd --- /dev/null +++ b/lib/features/account/data/models/city_model.dart @@ -0,0 +1,73 @@ +/// City Model +/// +/// Hive model for caching city/province data. +library; + +import 'package:hive_ce/hive.dart'; +import 'package:worker/core/constants/storage_constants.dart'; +import 'package:worker/features/account/domain/entities/city.dart'; + +part 'city_model.g.dart'; + +/// City Model +/// +/// Hive model for storing city/province data with offline support. +@HiveType(typeId: HiveTypeIds.cityModel) +class CityModel extends HiveObject { + /// Frappe ERPNext name/ID + @HiveField(0) + String name; + + /// Display name (city_name) + @HiveField(1) + String cityName; + + /// City code + @HiveField(2) + String code; + + CityModel({ + required this.name, + required this.cityName, + required this.code, + }); + + /// Create from JSON (API response) + factory CityModel.fromJson(Map json) { + return CityModel( + name: json['name'] as String? ?? '', + cityName: json['city_name'] as String? ?? '', + code: json['code'] as String? ?? '', + ); + } + + /// Convert to JSON (API request) + Map toJson() { + return { + 'name': name, + 'city_name': cityName, + 'code': code, + }; + } + + /// Convert to domain entity + City toEntity() { + return City( + name: name, + cityName: cityName, + code: code, + ); + } + + /// Create from domain entity + factory CityModel.fromEntity(City entity) { + return CityModel( + name: entity.name, + cityName: entity.cityName, + code: entity.code, + ); + } + + @override + String toString() => 'CityModel(name: $name, cityName: $cityName, code: $code)'; +} diff --git a/lib/features/account/data/models/city_model.g.dart b/lib/features/account/data/models/city_model.g.dart new file mode 100644 index 0000000..62b682e --- /dev/null +++ b/lib/features/account/data/models/city_model.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'city_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class CityModelAdapter extends TypeAdapter { + @override + final typeId = 31; + + @override + CityModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CityModel( + name: fields[0] as String, + cityName: fields[1] as String, + code: fields[2] as String, + ); + } + + @override + void write(BinaryWriter writer, CityModel obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.cityName) + ..writeByte(2) + ..write(obj.code); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CityModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/features/account/data/models/ward_model.dart b/lib/features/account/data/models/ward_model.dart new file mode 100644 index 0000000..143a478 --- /dev/null +++ b/lib/features/account/data/models/ward_model.dart @@ -0,0 +1,73 @@ +/// Ward Model +/// +/// Hive model for caching ward/district data. +library; + +import 'package:hive_ce/hive.dart'; +import 'package:worker/core/constants/storage_constants.dart'; +import 'package:worker/features/account/domain/entities/ward.dart'; + +part 'ward_model.g.dart'; + +/// Ward Model +/// +/// Hive model for storing ward/district data with offline support. +@HiveType(typeId: HiveTypeIds.wardModel) +class WardModel extends HiveObject { + /// Frappe ERPNext name/ID + @HiveField(0) + String name; + + /// Display name (ward_name) + @HiveField(1) + String wardName; + + /// Ward code + @HiveField(2) + String code; + + WardModel({ + required this.name, + required this.wardName, + required this.code, + }); + + /// Create from JSON (API response) + factory WardModel.fromJson(Map json) { + return WardModel( + name: json['name'] as String? ?? '', + wardName: json['ward_name'] as String? ?? '', + code: json['code'] as String? ?? '', + ); + } + + /// Convert to JSON (API request) + Map toJson() { + return { + 'name': name, + 'ward_name': wardName, + 'code': code, + }; + } + + /// Convert to domain entity + Ward toEntity() { + return Ward( + name: name, + wardName: wardName, + code: code, + ); + } + + /// Create from domain entity + factory WardModel.fromEntity(Ward entity) { + return WardModel( + name: entity.name, + wardName: entity.wardName, + code: entity.code, + ); + } + + @override + String toString() => 'WardModel(name: $name, wardName: $wardName, code: $code)'; +} diff --git a/lib/features/account/data/models/ward_model.g.dart b/lib/features/account/data/models/ward_model.g.dart new file mode 100644 index 0000000..d7e0d7d --- /dev/null +++ b/lib/features/account/data/models/ward_model.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ward_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class WardModelAdapter extends TypeAdapter { + @override + final typeId = 32; + + @override + WardModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return WardModel( + name: fields[0] as String, + wardName: fields[1] as String, + code: fields[2] as String, + ); + } + + @override + void write(BinaryWriter writer, WardModel obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.wardName) + ..writeByte(2) + ..write(obj.code); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WardModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/features/account/data/repositories/address_repository_impl.dart b/lib/features/account/data/repositories/address_repository_impl.dart new file mode 100644 index 0000000..250d5ce --- /dev/null +++ b/lib/features/account/data/repositories/address_repository_impl.dart @@ -0,0 +1,159 @@ +/// Address Repository Implementation +/// +/// Implements address repository with online-only API calls. +library; + +import 'package:worker/features/account/data/datasources/address_remote_datasource.dart'; +import 'package:worker/features/account/data/models/address_model.dart'; +import 'package:worker/features/account/domain/entities/address.dart'; +import 'package:worker/features/account/domain/repositories/address_repository.dart'; + +/// Address Repository Implementation +/// +/// Online-only implementation - all operations go directly to API. +/// No local caching or offline support. +class AddressRepositoryImpl implements AddressRepository { + final AddressRemoteDataSource _remoteDataSource; + + AddressRepositoryImpl({ + required AddressRemoteDataSource remoteDataSource, + }) : _remoteDataSource = remoteDataSource; + + @override + Future> getAddresses({bool? isDefault}) async { + _debugPrint('Getting addresses...'); + + try { + final addressModels = await _remoteDataSource.getAddresses( + isDefault: isDefault, + ); + + final addresses = addressModels.map((model) => model.toEntity()).toList(); + + _debugPrint('Retrieved ${addresses.length} addresses'); + return addresses; + } catch (e) { + _debugPrint('Error getting addresses: $e'); + rethrow; + } + } + + @override + Future
createAddress(Address address) async { + _debugPrint('Creating address: ${address.addressTitle}'); + + try { + // Create model with empty name (API will generate) + final addressModel = AddressModel.fromEntity(address).copyWith( + name: '', // Empty name indicates creation + ); + + final savedModel = await _remoteDataSource.saveAddress(addressModel); + + _debugPrint('Address created: ${savedModel.name}'); + return savedModel.toEntity(); + } catch (e) { + _debugPrint('Error creating address: $e'); + rethrow; + } + } + + @override + Future
updateAddress(Address address) async { + _debugPrint('Updating address: ${address.name}'); + + try { + final addressModel = AddressModel.fromEntity(address); + final savedModel = await _remoteDataSource.saveAddress(addressModel); + + _debugPrint('Address updated: ${savedModel.name}'); + return savedModel.toEntity(); + } catch (e) { + _debugPrint('Error updating address: $e'); + rethrow; + } + } + + @override + Future deleteAddress(String name) async { + _debugPrint('Deleting address: $name'); + + try { + await _remoteDataSource.deleteAddress(name); + _debugPrint('Address deleted: $name'); + } catch (e) { + _debugPrint('Error deleting address: $e'); + rethrow; + } + } + + @override + Future setDefaultAddress(String name) async { + _debugPrint('Setting default address: $name'); + + try { + // Get all addresses + final addresses = await getAddresses(); + + // Find the address to set as default + final targetAddress = addresses.firstWhere( + (addr) => addr.name == name, + orElse: () => throw Exception('Address not found: $name'), + ); + + // Update the target address to be default + await updateAddress(targetAddress.copyWith(isDefault: true)); + + // Update other addresses to not be default + for (final addr in addresses) { + if (addr.name != name && addr.isDefault) { + await updateAddress(addr.copyWith(isDefault: false)); + } + } + + _debugPrint('Default address set: $name'); + } catch (e) { + _debugPrint('Error setting default address: $e'); + rethrow; + } + } + + /// Debug print helper + void _debugPrint(String message) { + // ignore: avoid_print + print('[AddressRepository] $message'); + } +} + +/// Extension to create a copy with modifications (since AddressModel is not freezed) +extension _AddressModelCopyWith on AddressModel { + AddressModel copyWith({ + String? name, + String? addressTitle, + String? addressLine1, + String? phone, + String? email, + String? fax, + String? taxCode, + String? cityCode, + String? wardCode, + bool? isDefault, + String? cityName, + String? wardName, + }) { + return AddressModel( + name: name ?? this.name, + addressTitle: addressTitle ?? this.addressTitle, + addressLine1: addressLine1 ?? this.addressLine1, + phone: phone ?? this.phone, + email: email ?? this.email, + fax: fax ?? this.fax, + taxCode: taxCode ?? this.taxCode, + cityCode: cityCode ?? this.cityCode, + wardCode: wardCode ?? this.wardCode, + isDefault: isDefault ?? this.isDefault, + cityName: cityName ?? this.cityName, + wardName: wardName ?? this.wardName, + ); + } +} diff --git a/lib/features/account/data/repositories/location_repository_impl.dart b/lib/features/account/data/repositories/location_repository_impl.dart new file mode 100644 index 0000000..d98c2c6 --- /dev/null +++ b/lib/features/account/data/repositories/location_repository_impl.dart @@ -0,0 +1,96 @@ +/// Location Repository Implementation +/// +/// Implements location repository with offline-first strategy. +library; + +import 'package:worker/features/account/data/datasources/location_local_datasource.dart'; +import 'package:worker/features/account/data/datasources/location_remote_datasource.dart'; +import 'package:worker/features/account/domain/entities/city.dart'; +import 'package:worker/features/account/domain/entities/ward.dart'; +import 'package:worker/features/account/domain/repositories/location_repository.dart'; + +/// Location Repository Implementation +/// +/// Offline-first implementation: +/// - Cities: Cache in Hive, fetch from API if cache is empty or force refresh +/// - Wards: Cache per city, fetch from API if not cached or force refresh +class LocationRepositoryImpl implements LocationRepository { + final LocationRemoteDataSource _remoteDataSource; + final LocationLocalDataSource _localDataSource; + + LocationRepositoryImpl({ + required LocationRemoteDataSource remoteDataSource, + required LocationLocalDataSource localDataSource, + }) : _remoteDataSource = remoteDataSource, + _localDataSource = localDataSource; + + @override + Future> getCities({bool forceRefresh = false}) async { + try { + // Check cache first (offline-first) + if (!forceRefresh && _localDataSource.hasCities()) { + final cachedCities = _localDataSource.getCities(); + if (cachedCities.isNotEmpty) { + return cachedCities.map((model) => model.toEntity()).toList(); + } + } + + // Fetch from API + final cityModels = await _remoteDataSource.getCities(); + + // Save to cache + await _localDataSource.saveCities(cityModels); + + return cityModels.map((model) => model.toEntity()).toList(); + } catch (e) { + // Fallback to cache on error + if (!forceRefresh) { + final cachedCities = _localDataSource.getCities(); + if (cachedCities.isNotEmpty) { + return cachedCities.map((model) => model.toEntity()).toList(); + } + } + + rethrow; + } + } + + @override + Future> getWards( + String cityCode, { + bool forceRefresh = false, + }) async { + try { + // Check cache first (offline-first) + if (!forceRefresh && _localDataSource.hasWards(cityCode)) { + final cachedWards = _localDataSource.getWards(cityCode); + if (cachedWards.isNotEmpty) { + return cachedWards.map((model) => model.toEntity()).toList(); + } + } + + // Fetch from API + final wardModels = await _remoteDataSource.getWards(cityCode); + + // Save to cache + await _localDataSource.saveWards(cityCode, wardModels); + + return wardModels.map((model) => model.toEntity()).toList(); + } catch (e) { + // Fallback to cache on error + if (!forceRefresh) { + final cachedWards = _localDataSource.getWards(cityCode); + if (cachedWards.isNotEmpty) { + return cachedWards.map((model) => model.toEntity()).toList(); + } + } + + rethrow; + } + } + + @override + Future clearCache() async { + await _localDataSource.clearAll(); + } +} diff --git a/lib/features/account/domain/entities/address.dart b/lib/features/account/domain/entities/address.dart new file mode 100644 index 0000000..b96ecfa --- /dev/null +++ b/lib/features/account/domain/entities/address.dart @@ -0,0 +1,105 @@ +/// Address Entity +/// +/// Represents a delivery/billing address for the user. +/// Corresponds to Frappe ERPNext Address doctype. +library; + +import 'package:equatable/equatable.dart'; + +/// Address Entity +/// +/// Domain entity representing a user's delivery or billing address. +class Address extends Equatable { + final String name; + final String addressTitle; + final String addressLine1; + final String phone; + final String? email; + final String? fax; + final String? taxCode; + final String cityCode; + final String wardCode; + final bool isDefault; + final String? cityName; + final String? wardName; + + const Address({ + required this.name, + required this.addressTitle, + required this.addressLine1, + required this.phone, + this.email, + this.fax, + this.taxCode, + required this.cityCode, + required this.wardCode, + this.isDefault = false, + this.cityName, + this.wardName, + }); + + @override + List get props => [ + name, + addressTitle, + addressLine1, + phone, + email, + fax, + taxCode, + cityCode, + wardCode, + isDefault, + cityName, + wardName, + ]; + + /// Get full address display string + String get fullAddress { + final parts = []; + parts.add(addressLine1); + if (wardName != null && wardName!.isNotEmpty) { + parts.add(wardName!); + } + if (cityName != null && cityName!.isNotEmpty) { + parts.add(cityName!); + } + return parts.join(', '); + } + + /// Create a copy with modified fields + Address copyWith({ + String? name, + String? addressTitle, + String? addressLine1, + String? phone, + String? email, + String? fax, + String? taxCode, + String? cityCode, + String? wardCode, + bool? isDefault, + String? cityName, + String? wardName, + }) { + return Address( + name: name ?? this.name, + addressTitle: addressTitle ?? this.addressTitle, + addressLine1: addressLine1 ?? this.addressLine1, + phone: phone ?? this.phone, + email: email ?? this.email, + fax: fax ?? this.fax, + taxCode: taxCode ?? this.taxCode, + cityCode: cityCode ?? this.cityCode, + wardCode: wardCode ?? this.wardCode, + isDefault: isDefault ?? this.isDefault, + cityName: cityName ?? this.cityName, + wardName: wardName ?? this.wardName, + ); + } + + @override + String toString() { + return 'Address(name: $name, addressTitle: $addressTitle, addressLine1: $addressLine1, phone: $phone, isDefault: $isDefault)'; + } +} diff --git a/lib/features/account/domain/entities/city.dart b/lib/features/account/domain/entities/city.dart new file mode 100644 index 0000000..72f1111 --- /dev/null +++ b/lib/features/account/domain/entities/city.dart @@ -0,0 +1,27 @@ +/// City Entity +/// +/// Represents a city/province in Vietnam. +library; + +import 'package:equatable/equatable.dart'; + +/// City Entity +/// +/// Domain entity representing a city or province. +class City extends Equatable { + final String name; // Frappe ERPNext name/ID + final String cityName; // Display name + final String code; // City code + + const City({ + required this.name, + required this.cityName, + required this.code, + }); + + @override + List get props => [name, cityName, code]; + + @override + String toString() => 'City(name: $name, cityName: $cityName, code: $code)'; +} diff --git a/lib/features/account/domain/entities/ward.dart b/lib/features/account/domain/entities/ward.dart new file mode 100644 index 0000000..761644c --- /dev/null +++ b/lib/features/account/domain/entities/ward.dart @@ -0,0 +1,27 @@ +/// Ward Entity +/// +/// Represents a ward/district in a city. +library; + +import 'package:equatable/equatable.dart'; + +/// Ward Entity +/// +/// Domain entity representing a ward or district within a city. +class Ward extends Equatable { + final String name; // Frappe ERPNext name/ID + final String wardName; // Display name + final String code; // Ward code + + const Ward({ + required this.name, + required this.wardName, + required this.code, + }); + + @override + List get props => [name, wardName, code]; + + @override + String toString() => 'Ward(name: $name, wardName: $wardName, code: $code)'; +} diff --git a/lib/features/account/domain/repositories/address_repository.dart b/lib/features/account/domain/repositories/address_repository.dart new file mode 100644 index 0000000..8e741e3 --- /dev/null +++ b/lib/features/account/domain/repositories/address_repository.dart @@ -0,0 +1,38 @@ +/// Address Repository Interface +/// +/// Defines contract for address data operations. +library; + +import 'package:worker/features/account/domain/entities/address.dart'; + +/// Address Repository +/// +/// Repository interface for managing user addresses. +/// Online-only approach - all operations go directly to API. +abstract class AddressRepository { + /// Get list of addresses + /// + /// Fetches all addresses for the authenticated user. + /// Optionally filter by default address status. + Future> getAddresses({bool? isDefault}); + + /// Create new address + /// + /// Creates a new address and returns the created address with ID. + Future
createAddress(Address address); + + /// Update existing address + /// + /// Updates an existing address identified by its name (ID). + Future
updateAddress(Address address); + + /// Delete address + /// + /// Deletes an address by its name (ID). + Future deleteAddress(String name); + + /// Set address as default + /// + /// Marks the specified address as default and unmarks others. + Future setDefaultAddress(String name); +} diff --git a/lib/features/account/domain/repositories/location_repository.dart b/lib/features/account/domain/repositories/location_repository.dart new file mode 100644 index 0000000..137ec4d --- /dev/null +++ b/lib/features/account/domain/repositories/location_repository.dart @@ -0,0 +1,21 @@ +/// Location Repository Interface +/// +/// Contract for location (city/ward) data operations. +library; + +import 'package:worker/features/account/domain/entities/city.dart'; +import 'package:worker/features/account/domain/entities/ward.dart'; + +/// Location Repository +/// +/// Defines methods for accessing city and ward data. +abstract class LocationRepository { + /// Get all cities (offline-first: cache → API) + Future> getCities({bool forceRefresh = false}); + + /// Get wards for a specific city code + Future> getWards(String cityCode, {bool forceRefresh = false}); + + /// Clear all cached location data + Future clearCache(); +} diff --git a/lib/features/account/presentation/pages/address_form_page.dart b/lib/features/account/presentation/pages/address_form_page.dart new file mode 100644 index 0000000..e5117cc --- /dev/null +++ b/lib/features/account/presentation/pages/address_form_page.dart @@ -0,0 +1,1170 @@ +/// Address Form Page +/// +/// Create or edit a delivery address with full contact and location info. +/// Features: +/// - Contact information (name, phone, email, tax ID) +/// - Address selection (province/district with cascading dropdowns) +/// - Address detail text area +/// - Default address checkbox +/// - Form validation +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/account/domain/entities/address.dart'; +import 'package:worker/features/account/domain/entities/ward.dart'; +import 'package:worker/features/account/presentation/providers/address_provider.dart'; +import 'package:worker/features/account/presentation/providers/location_provider.dart'; + +/// Address Form Page +/// +/// Form for creating or editing a delivery address. +class AddressFormPage extends HookConsumerWidget { + const AddressFormPage({super.key, this.address}); + + /// Existing address for editing (null if creating new) + final Address? address; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Form key for validation + final formKey = useMemoized(() => GlobalKey()); + + // Form fields + final nameController = useTextEditingController( + text: address?.addressTitle ?? '', + ); + final phoneController = useTextEditingController( + text: address?.phone ?? '', + ); + final emailController = useTextEditingController( + text: address?.email ?? '', + ); + final taxIdController = useTextEditingController( + text: address?.taxCode ?? '', + ); + final addressDetailController = useTextEditingController( + text: address?.addressLine1 ?? '', + ); + + // Dropdown selections - use codes from Address entity + final selectedCityCode = useState(address?.cityCode); + final selectedWardCode = useState(address?.wardCode); + + // Default checkbox + final isDefault = useState(address?.isDefault ?? false); + + // Loading state + final isSaving = useState(false); + + // Get cities from API (offline-first) + final citiesAsync = ref.watch(citiesProvider); + + // Get wards for selected city from API (offline-first) + final AsyncValue> wardsAsync = selectedCityCode.value != null + ? ref.watch(wardsProvider(selectedCityCode.value!)) + : const AsyncValue>.data([]); + + // Build cities map for dropdown + final Map citiesMap = citiesAsync.when( + data: (cities) => { + for (final city in cities) city.code: city.cityName, + }, + loading: () => {}, + error: (_, __) => {}, + ); + + // Build wards map for dropdown + final Map wardsMap = wardsAsync.when( + data: (wards) => { + for (final ward in wards) ward.code: ward.wardName, + }, + loading: () => {}, + error: (_, __) => {}, + ); + + return Scaffold( + backgroundColor: const Color(0xFFF4F6F8), + appBar: AppBar( + backgroundColor: AppColors.white, + elevation: AppBarSpecs.elevation, + leading: IconButton( + icon: const FaIcon( + FontAwesomeIcons.arrowLeft, + color: Colors.black, + size: 20, + ), + onPressed: () => context.pop(), + ), + title: Text( + address == null ? 'Thêm địa chỉ mới' : 'Chỉnh sửa địa chỉ', + style: const TextStyle(color: Colors.black), + ), + foregroundColor: AppColors.grey900, + centerTitle: false, + actions: [ + IconButton( + icon: const FaIcon( + FontAwesomeIcons.circleInfo, + color: Colors.black, + size: 20, + ), + onPressed: () => _showInfoDialog(context), + ), + const SizedBox(width: AppSpacing.sm), + ], + ), + body: Stack( + children: [ + // Form Content + SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 100, // Space for sticky button + ), + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Contact Information Section + _buildSection( + icon: FontAwesomeIcons.user, + title: 'Thông tin liên hệ', + children: [ + _buildTextField( + controller: nameController, + label: 'Họ và tên', + icon: FontAwesomeIcons.user, + placeholder: 'Nhập họ và tên người nhận', + isRequired: true, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Vui lòng nhập họ và tên'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.md), + _buildTextField( + controller: phoneController, + label: 'Số điện thoại', + icon: FontAwesomeIcons.phone, + placeholder: 'Nhập số điện thoại', + keyboardType: TextInputType.phone, + isRequired: true, + helperText: 'Định dạng: 10-11 số', + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Vui lòng nhập số điện thoại'; + } + if (!RegExp(r'^[0-9]{10,11}$').hasMatch(value)) { + return 'Số điện thoại phải có 10-11 số'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.md), + _buildTextField( + controller: emailController, + label: 'Email', + icon: FontAwesomeIcons.envelope, + placeholder: 'Nhập email', + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value != null && + value.isNotEmpty && + !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(value)) { + return 'Email không hợp lệ'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.md), + _buildTextField( + controller: taxIdController, + label: 'Mã số thuế', + icon: FontAwesomeIcons.fileInvoice, + placeholder: 'Nhập mã số thuế', + keyboardType: TextInputType.number, + ), + ], + ), + + const SizedBox(height: AppSpacing.md), + + // Address Information Section + _buildSection( + icon: FontAwesomeIcons.locationDot, + title: 'Địa chỉ giao hàng', + children: [ + _buildDropdownWithLoading( + label: 'Tỉnh/Thành phố', + value: selectedCityCode.value, + items: citiesMap, + isRequired: true, + isLoading: citiesAsync.isLoading, + onChanged: (value) { + selectedCityCode.value = value; + selectedWardCode.value = + null; // Reset ward on city change + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Vui lòng chọn Tỉnh/Thành phố'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.md), + _buildDropdownWithLoading( + label: 'Quận/Huyện', + value: selectedWardCode.value, + items: wardsMap, + isRequired: true, + enabled: selectedCityCode.value != null, + isLoading: wardsAsync.isLoading, + onChanged: (value) { + selectedWardCode.value = value; + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Vui lòng chọn Quận/Huyện'; + } + return null; + }, + ), + // Show error if cities failed to load + if (citiesAsync.hasError) ...[ + const SizedBox(height: 8), + _buildErrorBanner( + 'Không thể tải danh sách tỉnh/thành phố. Vui lòng thử lại.', + ), + ], + // Show error if wards failed to load + if (wardsAsync.hasError && selectedCityCode.value != null) ...[ + const SizedBox(height: 8), + _buildErrorBanner( + 'Không thể tải danh sách quận/huyện. Vui lòng thử lại.', + ), + ], + const SizedBox(height: AppSpacing.md), + _buildTextArea( + controller: addressDetailController, + label: 'Địa chỉ cụ thể', + placeholder: 'Số nhà, tên đường, khu vực...', + isRequired: true, + helperText: 'Ví dụ: 123 Nguyễn Huệ, Khu phố 5', + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Vui lòng nhập địa chỉ cụ thể'; + } + return null; + }, + ), + ], + ), + + const SizedBox(height: AppSpacing.md), + + // Default Address Option + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppRadius.card), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => isDefault.value = !isDefault.value, + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: Checkbox( + value: isDefault.value, + onChanged: (value) => + isDefault.value = value ?? false, + activeColor: AppColors.primaryBlue, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 12), + const Text( + 'Đặt làm địa chỉ mặc định', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.only(left: 32), + child: Text( + 'Địa chỉ này sẽ được sử dụng làm mặc định khi đặt hàng', + style: TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: AppSpacing.md), + + // Info Note + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + border: Border.all( + color: const Color(0xFFBFDBFE), + width: 1, + ), + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FaIcon( + FontAwesomeIcons.circleInfo, + size: 18, + color: Color(0xFF2563EB), + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: const TextSpan( + style: TextStyle( + fontSize: 13, + color: Color(0xFF1E40AF), + height: 1.5, + ), + children: [ + TextSpan( + text: 'Lưu ý: ', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF1E3A8A), + ), + ), + TextSpan( + text: + 'Vui lòng kiểm tra kỹ thông tin địa chỉ để đảm bảo giao hàng chính xác. ' + 'Bạn có thể chỉnh sửa hoặc xóa địa chỉ này sau khi lưu.', + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + + // Sticky Footer with Save Button + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + decoration: BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide( + color: Colors.grey.withValues(alpha: 0.15), + ), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: isSaving.value + ? null + : () => _handleSubmit( + context, + ref, + formKey, + nameController, + phoneController, + emailController, + taxIdController, + selectedCityCode, + selectedWardCode, + addressDetailController, + isDefault, + isSaving, + ), + icon: isSaving.value + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const FaIcon(FontAwesomeIcons.floppyDisk, size: 18), + label: Text( + isSaving.value ? 'Đang lưu...' : 'Lưu địa chỉ', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.grey500, + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + /// Build a section with icon and title + Widget _buildSection({ + required IconData icon, + required String title, + required List children, + }) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppRadius.card), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FaIcon( + icon, + size: 16, + color: AppColors.primaryBlue, + ), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + ...children, + ], + ), + ); + } + + /// Build a text field with label and icon + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required IconData icon, + required String placeholder, + bool isRequired = false, + TextInputType? keyboardType, + String? helperText, + String? Function(String?)? validator, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + ), + ), + if (isRequired) + const Text( + ' *', + style: TextStyle( + fontSize: 14, + color: AppColors.danger, + ), + ), + ], + ), + const SizedBox(height: 8), + TextFormField( + controller: controller, + keyboardType: keyboardType, + validator: validator, + decoration: InputDecoration( + hintText: placeholder, + hintStyle: const TextStyle(color: AppColors.grey500), + prefixIcon: Padding( + padding: const EdgeInsets.only(left: 16, right: 12), + child: FaIcon( + icon, + size: 16, + color: AppColors.grey500, + ), + ), + prefixIconConstraints: const BoxConstraints( + minWidth: 0, + minHeight: 0, + ), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide( + color: AppColors.primaryBlue, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: AppColors.danger), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide( + color: AppColors.danger, + width: 2, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + if (helperText != null) ...[ + const SizedBox(height: 4), + Text( + helperText, + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + ], + ], + ); + } + + /// Build a dropdown field + Widget _buildDropdown({ + required String label, + required String? value, + required Map items, + required void Function(String?) onChanged, + bool isRequired = false, + bool enabled = true, + String? Function(String?)? validator, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + ), + ), + if (isRequired) + const Text( + ' *', + style: TextStyle( + fontSize: 14, + color: AppColors.danger, + ), + ), + ], + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: items.containsKey(value) ? value : null, + isExpanded: true, + validator: validator, + decoration: InputDecoration( + filled: true, + fillColor: enabled ? Colors.white : const Color(0xFFF3F4F6), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide( + color: AppColors.primaryBlue, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: AppColors.danger), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide( + color: AppColors.danger, + width: 2, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + hint: Text( + '-- Chọn $label --', + style: const TextStyle(color: AppColors.grey500), + ), + items: enabled + ? () { + final itemsList = items.entries.map((entry) { + return DropdownMenuItem( + value: entry.key, + child: Text( + entry.value, + style: const TextStyle( + fontSize: 14, + color: AppColors.grey900, + ), + ), + ); + }).toList(); + + // If value exists but not in items, add it as a placeholder + if (value != null && !items.containsKey(value)) { + itemsList.insert( + 0, + DropdownMenuItem( + value: value, + child: Text( + '$value (đã lưu)', + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + fontStyle: FontStyle.italic, + ), + ), + ), + ); + } + + return itemsList; + }() + : null, + onChanged: enabled ? onChanged : null, + icon: FaIcon( + FontAwesomeIcons.chevronDown, + size: 14, + color: enabled ? AppColors.grey500 : AppColors.grey500.withValues(alpha: 0.5), + ), + ), + ], + ); + } + + /// Build a dropdown field with loading indicator + Widget _buildDropdownWithLoading({ + required String label, + required String? value, + required Map items, + required void Function(String?) onChanged, + bool isRequired = false, + bool enabled = true, + bool isLoading = false, + String? Function(String?)? validator, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + ), + ), + if (isRequired) + const Text( + ' *', + style: TextStyle( + fontSize: 14, + color: AppColors.danger, + ), + ), + if (isLoading) ...[ + const SizedBox(width: 8), + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primaryBlue, + ), + ), + ], + ], + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: items.containsKey(value) ? value : null, + isExpanded: true, + validator: validator, + decoration: InputDecoration( + filled: true, + fillColor: enabled && !isLoading ? Colors.white : const Color(0xFFF3F4F6), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide( + color: AppColors.primaryBlue, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: AppColors.danger), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide( + color: AppColors.danger, + width: 2, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + suffixIcon: isLoading + ? const Padding( + padding: EdgeInsets.only(right: 12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primaryBlue, + ), + ), + ) + : null, + ), + hint: Text( + isLoading + ? 'Đang tải...' + : !enabled + ? 'Vui lòng chọn Tỉnh/Thành phố trước' + : '-- Chọn $label --', + style: TextStyle( + color: AppColors.grey500, + fontSize: 14, + fontStyle: !enabled || isLoading ? FontStyle.italic : FontStyle.normal, + ), + ), + items: enabled && !isLoading + ? () { + final itemsList = items.entries.map((entry) { + return DropdownMenuItem( + value: entry.key, + child: Text( + entry.value, + style: const TextStyle( + fontSize: 14, + color: AppColors.grey900, + ), + ), + ); + }).toList(); + + // If value exists but not in items, add it as a placeholder + if (value != null && !items.containsKey(value)) { + itemsList.insert( + 0, + DropdownMenuItem( + value: value, + child: Text( + '$value (đã lưu)', + style: const TextStyle( + fontSize: 14, + color: AppColors.grey500, + fontStyle: FontStyle.italic, + ), + ), + ), + ); + } + + return itemsList; + }() + : null, + onChanged: enabled && !isLoading ? onChanged : null, + icon: FaIcon( + FontAwesomeIcons.chevronDown, + size: 14, + color: enabled && !isLoading + ? AppColors.grey500 + : AppColors.grey500.withValues(alpha: 0.5), + ), + ), + ], + ); + } + + /// Build a text area field + Widget _buildTextArea({ + required TextEditingController controller, + required String label, + required String placeholder, + bool isRequired = false, + String? helperText, + String? Function(String?)? validator, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + ), + ), + if (isRequired) + const Text( + ' *', + style: TextStyle( + fontSize: 14, + color: AppColors.danger, + ), + ), + ], + ), + const SizedBox(height: 8), + TextFormField( + controller: controller, + maxLines: 3, + validator: validator, + decoration: InputDecoration( + hintText: placeholder, + hintStyle: const TextStyle(color: AppColors.grey500), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide( + color: AppColors.primaryBlue, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide(color: AppColors.danger), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: const BorderSide( + color: AppColors.danger, + width: 2, + ), + ), + contentPadding: const EdgeInsets.all(16), + ), + ), + if (helperText != null) ...[ + const SizedBox(height: 4), + Text( + helperText, + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + ], + ], + ); + } + + /// Build error banner for API failures + Widget _buildErrorBanner(String message) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + border: Border.all( + color: const Color(0xFFFECACA), + width: 1, + ), + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Row( + children: [ + const FaIcon( + FontAwesomeIcons.circleExclamation, + size: 16, + color: Color(0xFFDC2626), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF991B1B), + height: 1.4, + ), + ), + ), + ], + ), + ); + } + + /// Show info dialog + void _showInfoDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text( + 'Hướng dẫn điền địa chỉ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: const SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Vui lòng điền đầy đủ thông tin:'), + SizedBox(height: 12), + Text('• Họ và tên người nhận hàng'), + Text('• Số điện thoại liên hệ (10-11 số)'), + Text('• Email (không bắt buộc)'), + Text('• Mã số thuế (nếu có)'), + Text('• Tỉnh/Thành phố và Quận/Huyện'), + Text('• Địa chỉ chi tiết (số nhà, đường, khu vực)'), + SizedBox(height: 12), + Text( + 'Địa chỉ được đánh dấu "mặc định" sẽ tự động ' + 'được chọn khi đặt hàng.', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Đóng'), + ), + ], + ), + ); + } + + /// Handle form submission + Future _handleSubmit( + BuildContext context, + WidgetRef ref, + GlobalKey formKey, + TextEditingController nameController, + TextEditingController phoneController, + TextEditingController emailController, + TextEditingController taxIdController, + ValueNotifier selectedCityCode, + ValueNotifier selectedWardCode, + TextEditingController addressDetailController, + ValueNotifier isDefault, + ValueNotifier isSaving, + ) async { + // Validate form + if (!formKey.currentState!.validate()) { + return; + } + + // Set loading state + isSaving.value = true; + + try { + // Build Address entity + final newAddress = Address( + name: address?.name ?? '', // Empty for creation, existing for update + addressTitle: nameController.text.trim(), + addressLine1: addressDetailController.text.trim(), + phone: phoneController.text.trim(), + email: emailController.text.trim().isEmpty ? null : emailController.text.trim(), + taxCode: taxIdController.text.trim().isEmpty ? null : taxIdController.text.trim(), + cityCode: selectedCityCode.value!, + wardCode: selectedWardCode.value!, + isDefault: isDefault.value, + ); + + // Call API to create or update + if (address == null) { + // Creating new address + await ref.read(addressesProvider.notifier).createAddress(newAddress); + } else { + // Updating existing address + await ref.read(addressesProvider.notifier).updateAddress(newAddress); + } + + // Reset loading state + isSaving.value = false; + + // Show success message + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const FaIcon(FontAwesomeIcons.circleCheck, color: Colors.white, size: 18), + const SizedBox(width: 12), + Text(address == null ? 'Đã thêm địa chỉ thành công!' : 'Đã cập nhật địa chỉ thành công!'), + ], + ), + backgroundColor: const Color(0xFF10B981), + duration: const Duration(seconds: 2), + ), + ); + + // Navigate back after short delay + if (context.mounted) { + Future.delayed(const Duration(milliseconds: 500), () { + if (context.mounted) { + context.pop(); + } + }); + } + } catch (e) { + // Reset loading state + isSaving.value = false; + + // Show error message + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const FaIcon(FontAwesomeIcons.circleExclamation, color: Colors.white, size: 18), + const SizedBox(width: 12), + Expanded(child: Text('Lỗi: ${e.toString()}')), + ], + ), + backgroundColor: AppColors.danger, + duration: const Duration(seconds: 3), + ), + ); + } + } +} diff --git a/lib/features/account/presentation/pages/addresses_page.dart b/lib/features/account/presentation/pages/addresses_page.dart index ee649ac..541025f 100644 --- a/lib/features/account/presentation/pages/addresses_page.dart +++ b/lib/features/account/presentation/pages/addresses_page.dart @@ -10,136 +10,163 @@ library; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/router/app_router.dart'; import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/account/domain/entities/address.dart'; +import 'package:worker/features/account/presentation/providers/address_provider.dart'; import 'package:worker/features/account/presentation/widgets/address_card.dart'; /// Addresses Page /// /// Page for managing saved delivery addresses. -class AddressesPage extends HookConsumerWidget { +class AddressesPage extends ConsumerWidget { const AddressesPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - // Mock addresses data - final addresses = useState>>([ - { - 'id': '1', - 'name': 'Hoàng Minh Hiệp', - 'phone': '0347302911', - 'address': - '123 Đường Võ Văn Ngân, Phường Linh Chiểu, Thành phố Thủ Đức, TP.HCM', - 'isDefault': true, - }, - { - 'id': '2', - 'name': 'Hoàng Minh Hiệp', - 'phone': '0347302911', - 'address': '456 Đường Nguyễn Thị Minh Khai, Quận 3, TP.HCM', - 'isDefault': false, - }, - { - 'id': '3', - 'name': 'Công ty TNHH ABC', - 'phone': '0283445566', - 'address': '789 Đường Lê Văn Sỹ, Quận Phú Nhuận, TP.HCM', - 'isDefault': false, - }, - ]); + // Watch addresses from API + final addressesAsync = ref.watch(addressesProvider); return Scaffold( backgroundColor: const Color(0xFFF4F6F8), appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, + backgroundColor: AppColors.white, + elevation: AppBarSpecs.elevation, leading: IconButton( icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20), onPressed: () => context.pop(), ), title: const Text( - 'Địa chỉ đã lưu', - style: TextStyle( - color: Colors.black, - fontSize: 18, - fontWeight: FontWeight.bold, - ), + 'Địa chỉ của bạn', + style: TextStyle(color: Colors.black), ), + foregroundColor: AppColors.grey900, centerTitle: false, actions: [ IconButton( - icon: const FaIcon(FontAwesomeIcons.plus, color: Colors.black, size: 20), + icon: const FaIcon(FontAwesomeIcons.circleInfo, color: Colors.black, size: 20), onPressed: () { - _showAddAddress(context); + _showInfoDialog(context); }, ), const SizedBox(width: AppSpacing.sm), ], ), - body: Column( - children: [ - // Address List - Expanded( - child: addresses.value.isEmpty - ? _buildEmptyState(context) - : ListView.separated( - padding: const EdgeInsets.all(AppSpacing.md), - itemCount: addresses.value.length, - separatorBuilder: (context, index) => - const SizedBox(height: AppSpacing.md), - itemBuilder: (context, index) { - final address = addresses.value[index]; - return AddressCard( - name: address['name'] as String, - phone: address['phone'] as String, - address: address['address'] as String, - isDefault: address['isDefault'] as bool, - onEdit: () { - _showEditAddress(context, address); - }, - onDelete: () { - _showDeleteConfirmation(context, addresses, index); - }, - onSetDefault: () { - _setDefaultAddress(addresses, index); - }, - ); - }, - ), - ), - - // Add New Address Button - Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - _showAddAddress(context); + body: addressesAsync.when( + data: (addresses) => Column( + children: [ + // Address List + Expanded( + child: RefreshIndicator( + onRefresh: () async { + await ref.read(addressesProvider.notifier).refresh(); }, - icon: const FaIcon(FontAwesomeIcons.plus, size: 18), - label: const Text( - 'Thêm địa chỉ mới', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryBlue, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), + child: addresses.isEmpty + ? _buildEmptyState(context) + : ListView.separated( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: addresses.length, + separatorBuilder: (context, index) => + const SizedBox(height: AppSpacing.md), + itemBuilder: (context, index) { + final address = addresses[index]; + return AddressCard( + name: address.addressTitle, + phone: address.phone, + address: address.fullAddress, + isDefault: address.isDefault, + onEdit: () { + context.push( + RouteNames.addressForm, + extra: address, + ); + }, + onDelete: () { + _showDeleteConfirmation(context, ref, address); + }, + onSetDefault: () { + _setDefaultAddress(context, ref, address); + }, + ); + }, + ), + ), + ), + + // Add New Address Button + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + context.push(RouteNames.addressForm); + }, + icon: const FaIcon(FontAwesomeIcons.plus, size: 18), + label: const Text( + 'Thêm địa chỉ mới', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), ), ), ), ), + ], + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FaIcon( + FontAwesomeIcons.triangleExclamation, + size: 64, + color: AppColors.danger, + ), + const SizedBox(height: 16), + const Text( + 'Không thể tải danh sách địa chỉ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + const SizedBox(height: 8), + Text( + error.toString(), + style: const TextStyle(fontSize: 14, color: AppColors.grey500), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + ref.read(addressesProvider.notifier).refresh(); + }, + icon: const FaIcon(FontAwesomeIcons.arrowsRotate, size: 18), + label: const Text('Thử lại'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + ), + ), + ], ), - ], + ), ), + ); } @@ -152,7 +179,7 @@ class AddressesPage extends HookConsumerWidget { FaIcon( FontAwesomeIcons.locationDot, size: 64, - color: AppColors.grey500.withValues(alpha: 0.5), + color: AppColors.grey500.withValues(alpha: 0.4), ), const SizedBox(height: 16), const Text( @@ -160,18 +187,21 @@ class AddressesPage extends HookConsumerWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: AppColors.grey500, + color: AppColors.grey900, ), ), const SizedBox(height: 8), - const Text( + Text( 'Thêm địa chỉ để nhận hàng nhanh hơn', - style: TextStyle(fontSize: 14, color: AppColors.grey500), + style: TextStyle( + fontSize: 14, + color: AppColors.grey500.withValues(alpha: 0.8), + ), ), const SizedBox(height: 24), ElevatedButton.icon( onPressed: () { - _showAddAddress(context); + context.push(RouteNames.addressForm); }, icon: const FaIcon(FontAwesomeIcons.plus, size: 18), label: const Text( @@ -194,34 +224,57 @@ class AddressesPage extends HookConsumerWidget { } /// Set address as default - void _setDefaultAddress( - ValueNotifier>> addresses, - int index, - ) { - final updatedAddresses = addresses.value.map((address) { - return {...address, 'isDefault': false}; - }).toList(); + void _setDefaultAddress(BuildContext context, WidgetRef ref, Address address) { + ref.read(addressesProvider.notifier).setDefaultAddress(address.name); - updatedAddresses[index]['isDefault'] = true; - addresses.value = updatedAddresses; - } - - /// Show add address dialog (TODO: implement form page) - void _showAddAddress(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Chức năng thêm địa chỉ mới sẽ được phát triển'), - duration: Duration(seconds: 2), + SnackBar( + content: Row( + children: [ + const FaIcon( + FontAwesomeIcons.circleCheck, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 12), + const Text('Đã đặt làm địa chỉ mặc định'), + ], + ), + backgroundColor: const Color(0xFF10B981), + duration: const Duration(seconds: 2), ), ); } - /// Show edit address dialog (TODO: implement form page) - void _showEditAddress(BuildContext context, Map address) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Chỉnh sửa địa chỉ: ${address['name']}'), - duration: const Duration(seconds: 2), + /// Show info dialog + void _showInfoDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text( + 'Hướng dẫn sử dụng', + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: const SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Quản lý địa chỉ giao hàng của bạn:'), + SizedBox(height: 12), + Text('• Thêm địa chỉ mới để dễ dàng đặt hàng'), + Text('• Đặt địa chỉ mặc định cho đơn hàng'), + Text('• Chỉnh sửa hoặc xóa địa chỉ bất kỳ'), + Text('• Lưu nhiều địa chỉ cho các mục đích khác nhau'), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Đóng'), + ), + ], ), ); } @@ -229,8 +282,8 @@ class AddressesPage extends HookConsumerWidget { /// Show delete confirmation dialog void _showDeleteConfirmation( BuildContext context, - ValueNotifier>> addresses, - int index, + WidgetRef ref, + Address address, ) { showDialog( context: context, @@ -245,7 +298,7 @@ class AddressesPage extends HookConsumerWidget { TextButton( onPressed: () { Navigator.pop(context); - _deleteAddress(context, addresses, index); + _deleteAddress(context, ref, address); }, style: TextButton.styleFrom(foregroundColor: AppColors.danger), child: const Text('Xóa'), @@ -258,26 +311,51 @@ class AddressesPage extends HookConsumerWidget { /// Delete address void _deleteAddress( BuildContext context, - ValueNotifier>> addresses, - int index, - ) { - final deletedAddress = addresses.value[index]; - final updatedAddresses = List>.from(addresses.value); - updatedAddresses.removeAt(index); + WidgetRef ref, + Address address, + ) async { + try { + await ref.read(addressesProvider.notifier).deleteAddress(address.name); - // If deleted address was default and there are other addresses, - // set the first one as default - if (deletedAddress['isDefault'] == true && updatedAddresses.isNotEmpty) { - updatedAddresses[0]['isDefault'] = true; + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const FaIcon( + FontAwesomeIcons.circleCheck, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 12), + const Text('Đã xóa địa chỉ'), + ], + ), + backgroundColor: const Color(0xFF10B981), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const FaIcon( + FontAwesomeIcons.circleExclamation, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 12), + Text('Lỗi: ${e.toString()}'), + ], + ), + backgroundColor: AppColors.danger, + duration: const Duration(seconds: 3), + ), + ); + } } - - addresses.value = updatedAddresses; - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Đã xóa địa chỉ'), - duration: Duration(seconds: 2), - ), - ); } } diff --git a/lib/features/account/presentation/providers/address_provider.dart b/lib/features/account/presentation/providers/address_provider.dart new file mode 100644 index 0000000..eb1a88f --- /dev/null +++ b/lib/features/account/presentation/providers/address_provider.dart @@ -0,0 +1,221 @@ +/// Address Provider +/// +/// Riverpod providers for address management. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/features/account/data/datasources/address_remote_datasource.dart'; +import 'package:worker/features/account/data/repositories/address_repository_impl.dart'; +import 'package:worker/features/account/domain/entities/address.dart'; +import 'package:worker/features/account/domain/repositories/address_repository.dart'; + +part 'address_provider.g.dart'; + +// ============================================================================ +// DATASOURCE PROVIDER +// ============================================================================ + +/// Provides instance of AddressRemoteDataSource +@riverpod +Future addressRemoteDataSource(Ref ref) async { + final dioClient = await ref.watch(dioClientProvider.future); + return AddressRemoteDataSource(dioClient.dio); +} + +// ============================================================================ +// REPOSITORY PROVIDER +// ============================================================================ + +/// Provides instance of AddressRepository +@riverpod +Future addressRepository(Ref ref) async { + final remoteDataSource = + await ref.watch(addressRemoteDataSourceProvider.future); + + return AddressRepositoryImpl( + remoteDataSource: remoteDataSource, + ); +} + +// ============================================================================ +// ADDRESSES LIST PROVIDER +// ============================================================================ + +/// Manages list of addresses with online-only approach +/// +/// This is the MAIN provider for the addresses feature. +/// Returns list of Address entities from the API. +/// +/// Online-only: Always fetches from API, no offline caching. +/// Uses keepAlive to prevent unnecessary reloads. +/// Provides refresh() method for pull-to-refresh functionality. +@Riverpod(keepAlive: true) +class Addresses extends _$Addresses { + late AddressRepository _repository; + + @override + Future> build() async { + _repository = await ref.read(addressRepositoryProvider.future); + return await _loadAddresses(); + } + + // ========================================================================== + // PRIVATE METHODS + // ========================================================================== + + /// Load addresses from repository + /// + /// Online-only: Fetches from API + Future> _loadAddresses() async { + try { + final addresses = await _repository.getAddresses(); + _debugPrint('Loaded ${addresses.length} addresses'); + return addresses; + } catch (e) { + _debugPrint('Error loading addresses: $e'); + rethrow; + } + } + + // ========================================================================== + // PUBLIC METHODS + // ========================================================================== + + /// Create new address + /// + /// Calls API to create address, then refreshes the list. + Future createAddress(Address address) async { + try { + _debugPrint('Creating address: ${address.addressTitle}'); + + await _repository.createAddress(address); + + // Refresh the list after successful creation + await refresh(); + + _debugPrint('Successfully created address'); + } catch (e) { + _debugPrint('Error creating address: $e'); + rethrow; + } + } + + /// Update existing address + /// + /// Calls API to update address, then refreshes the list. + Future updateAddress(Address address) async { + try { + _debugPrint('Updating address: ${address.name}'); + + await _repository.updateAddress(address); + + // Refresh the list after successful update + await refresh(); + + _debugPrint('Successfully updated address'); + } catch (e) { + _debugPrint('Error updating address: $e'); + rethrow; + } + } + + /// Delete address + /// + /// Calls API to delete address, then refreshes the list. + Future deleteAddress(String name) async { + try { + _debugPrint('Deleting address: $name'); + + await _repository.deleteAddress(name); + + // Refresh the list after successful deletion + await refresh(); + + _debugPrint('Successfully deleted address'); + } catch (e) { + _debugPrint('Error deleting address: $e'); + rethrow; + } + } + + /// Set address as default + /// + /// Calls API to set address as default, then refreshes the list. + Future setDefaultAddress(String name) async { + try { + _debugPrint('Setting default address: $name'); + + await _repository.setDefaultAddress(name); + + // Refresh the list after successful update + await refresh(); + + _debugPrint('Successfully set default address'); + } catch (e) { + _debugPrint('Error setting default address: $e'); + rethrow; + } + } + + /// Refresh addresses from API + /// + /// Used for pull-to-refresh functionality. + /// Fetches latest data from API. + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + return await _loadAddresses(); + }); + } +} + +// ============================================================================ +// HELPER PROVIDERS +// ============================================================================ + +/// Get the default address +/// +/// Derived from the addresses list. +/// Returns the address marked as default, or null if none. +@riverpod +Address? defaultAddress(Ref ref) { + final addressesAsync = ref.watch(addressesProvider); + + return addressesAsync.when( + data: (addresses) { + try { + return addresses.firstWhere((addr) => addr.isDefault); + } catch (e) { + return null; + } + }, + loading: () => null, + error: (_, __) => null, + ); +} + +/// Get address count +/// +/// Derived from the addresses list. +/// Returns the number of addresses. +@riverpod +int addressCount(Ref ref) { + final addressesAsync = ref.watch(addressesProvider); + + return addressesAsync.when( + data: (addresses) => addresses.length, + loading: () => 0, + error: (_, __) => 0, + ); +} + +// ============================================================================ +// DEBUG UTILITIES +// ============================================================================ + +/// Debug print helper +void _debugPrint(String message) { + // ignore: avoid_print + print('[AddressProvider] $message'); +} diff --git a/lib/features/account/presentation/providers/address_provider.g.dart b/lib/features/account/presentation/providers/address_provider.g.dart new file mode 100644 index 0000000..903b01d --- /dev/null +++ b/lib/features/account/presentation/providers/address_provider.g.dart @@ -0,0 +1,290 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'address_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Provides instance of AddressRemoteDataSource + +@ProviderFor(addressRemoteDataSource) +const addressRemoteDataSourceProvider = AddressRemoteDataSourceProvider._(); + +/// Provides instance of AddressRemoteDataSource + +final class AddressRemoteDataSourceProvider + extends + $FunctionalProvider< + AsyncValue, + AddressRemoteDataSource, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Provides instance of AddressRemoteDataSource + const AddressRemoteDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'addressRemoteDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$addressRemoteDataSourceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return addressRemoteDataSource(ref); + } +} + +String _$addressRemoteDataSourceHash() => + r'e244b9f1270d1b81d65b82a9d5b34ead33bd7b79'; + +/// Provides instance of AddressRepository + +@ProviderFor(addressRepository) +const addressRepositoryProvider = AddressRepositoryProvider._(); + +/// Provides instance of AddressRepository + +final class AddressRepositoryProvider + extends + $FunctionalProvider< + AsyncValue, + AddressRepository, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Provides instance of AddressRepository + const AddressRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'addressRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$addressRepositoryHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return addressRepository(ref); + } +} + +String _$addressRepositoryHash() => r'87d8fa124d6f32c4f073acd30ba09b1eee5b0227'; + +/// Manages list of addresses with online-only approach +/// +/// This is the MAIN provider for the addresses feature. +/// Returns list of Address entities from the API. +/// +/// Online-only: Always fetches from API, no offline caching. +/// Uses keepAlive to prevent unnecessary reloads. +/// Provides refresh() method for pull-to-refresh functionality. + +@ProviderFor(Addresses) +const addressesProvider = AddressesProvider._(); + +/// Manages list of addresses with online-only approach +/// +/// This is the MAIN provider for the addresses feature. +/// Returns list of Address entities from the API. +/// +/// Online-only: Always fetches from API, no offline caching. +/// Uses keepAlive to prevent unnecessary reloads. +/// Provides refresh() method for pull-to-refresh functionality. +final class AddressesProvider + extends $AsyncNotifierProvider> { + /// Manages list of addresses with online-only approach + /// + /// This is the MAIN provider for the addresses feature. + /// Returns list of Address entities from the API. + /// + /// Online-only: Always fetches from API, no offline caching. + /// Uses keepAlive to prevent unnecessary reloads. + /// Provides refresh() method for pull-to-refresh functionality. + const AddressesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'addressesProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$addressesHash(); + + @$internal + @override + Addresses create() => Addresses(); +} + +String _$addressesHash() => r'c8018cffc89b03e687052802d3d0cd16cd1d5047'; + +/// Manages list of addresses with online-only approach +/// +/// This is the MAIN provider for the addresses feature. +/// Returns list of Address entities from the API. +/// +/// Online-only: Always fetches from API, no offline caching. +/// Uses keepAlive to prevent unnecessary reloads. +/// Provides refresh() method for pull-to-refresh functionality. + +abstract class _$Addresses extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref>, List
>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier>, List
>, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// Get the default address +/// +/// Derived from the addresses list. +/// Returns the address marked as default, or null if none. + +@ProviderFor(defaultAddress) +const defaultAddressProvider = DefaultAddressProvider._(); + +/// Get the default address +/// +/// Derived from the addresses list. +/// Returns the address marked as default, or null if none. + +final class DefaultAddressProvider + extends $FunctionalProvider + with $Provider { + /// Get the default address + /// + /// Derived from the addresses list. + /// Returns the address marked as default, or null if none. + const DefaultAddressProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'defaultAddressProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$defaultAddressHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Address? create(Ref ref) { + return defaultAddress(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Address? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$defaultAddressHash() => r'debdc71d6a480cf1ceb9536a4b6d9690aede1d72'; + +/// Get address count +/// +/// Derived from the addresses list. +/// Returns the number of addresses. + +@ProviderFor(addressCount) +const addressCountProvider = AddressCountProvider._(); + +/// Get address count +/// +/// Derived from the addresses list. +/// Returns the number of addresses. + +final class AddressCountProvider extends $FunctionalProvider + with $Provider { + /// Get address count + /// + /// Derived from the addresses list. + /// Returns the number of addresses. + const AddressCountProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'addressCountProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$addressCountHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + int create(Ref ref) { + return addressCount(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(int value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$addressCountHash() => r'e4480805fd484cd477fd0f232902afdfdd0ed342'; diff --git a/lib/features/account/presentation/providers/location_provider.dart b/lib/features/account/presentation/providers/location_provider.dart new file mode 100644 index 0000000..9afc260 --- /dev/null +++ b/lib/features/account/presentation/providers/location_provider.dart @@ -0,0 +1,153 @@ +/// Location Provider +/// +/// Riverpod providers for cities and wards management. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/database/hive_service.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/features/account/data/datasources/location_local_datasource.dart'; +import 'package:worker/features/account/data/datasources/location_remote_datasource.dart'; +import 'package:worker/features/account/data/repositories/location_repository_impl.dart'; +import 'package:worker/features/account/domain/entities/city.dart'; +import 'package:worker/features/account/domain/entities/ward.dart'; +import 'package:worker/features/account/domain/repositories/location_repository.dart'; + +part 'location_provider.g.dart'; + +// ============================================================================ +// DATASOURCE PROVIDERS +// ============================================================================ + +/// Provides instance of LocationRemoteDataSource +@riverpod +Future locationRemoteDataSource(Ref ref) async { + final dioClient = await ref.watch(dioClientProvider.future); + return LocationRemoteDataSource(dioClient.dio); +} + +/// Provides instance of LocationLocalDataSource +@riverpod +LocationLocalDataSource locationLocalDataSource(Ref ref) { + final hiveService = HiveService(); + return LocationLocalDataSource(hiveService); +} + +// ============================================================================ +// REPOSITORY PROVIDER +// ============================================================================ + +/// Provides instance of LocationRepository +@riverpod +Future locationRepository(Ref ref) async { + final remoteDataSource = await ref.watch(locationRemoteDataSourceProvider.future); + final localDataSource = ref.watch(locationLocalDataSourceProvider); + + return LocationRepositoryImpl( + remoteDataSource: remoteDataSource, + localDataSource: localDataSource, + ); +} + +// ============================================================================ +// CITIES PROVIDER +// ============================================================================ + +/// Manages list of cities with offline-first approach +/// +/// This is the MAIN provider for cities. +/// Returns list of City entities (cached → API). +@Riverpod(keepAlive: true) +class Cities extends _$Cities { + late LocationRepository _repository; + + @override + Future> build() async { + _repository = await ref.read(locationRepositoryProvider.future); + return await _loadCities(); + } + + /// Load cities (offline-first) + Future> _loadCities({bool forceRefresh = false}) async { + try { + final cities = await _repository.getCities(forceRefresh: forceRefresh); + return cities; + } catch (e) { + rethrow; + } + } + + /// Refresh cities from API + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + return await _loadCities(forceRefresh: true); + }); + } +} + +// ============================================================================ +// WARDS PROVIDER (per city) +// ============================================================================ + +/// Manages list of wards for a specific city with offline-first approach +/// +/// Uses .family modifier to create a provider per city code. +/// Returns list of Ward entities (cached → API). +@riverpod +Future> wards(Ref ref, String cityCode) async { + final repository = await ref.watch(locationRepositoryProvider.future); + + try { + final wards = await repository.getWards(cityCode); + return wards; + } catch (e) { + rethrow; + } +} + +// ============================================================================ +// HELPER PROVIDERS +// ============================================================================ + +/// Get city by code +@riverpod +City? cityByCode(Ref ref, String code) { + final citiesAsync = ref.watch(citiesProvider); + + return citiesAsync.when( + data: (cities) { + try { + return cities.firstWhere((city) => city.code == code); + } catch (e) { + return null; + } + }, + loading: () => null, + error: (_, __) => null, + ); +} + +/// Get cities as map (code → City) for easy lookup +@riverpod +Map citiesMap(Ref ref) { + final citiesAsync = ref.watch(citiesProvider); + + return citiesAsync.when( + data: (cities) => {for (final city in cities) city.code: city}, + loading: () => {}, + error: (_, __) => {}, + ); +} + +/// Get wards as map (code → Ward) for a city +@riverpod +Map wardsMap(Ref ref, String cityCode) { + final wardsAsync = ref.watch(wardsProvider(cityCode)); + + return wardsAsync.when( + data: (wards) => {for (final ward in wards) ward.code: ward}, + loading: () => {}, + error: (_, __) => {}, + ); +} diff --git a/lib/features/account/presentation/providers/location_provider.g.dart b/lib/features/account/presentation/providers/location_provider.g.dart new file mode 100644 index 0000000..873e93b --- /dev/null +++ b/lib/features/account/presentation/providers/location_provider.g.dart @@ -0,0 +1,545 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'location_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Provides instance of LocationRemoteDataSource + +@ProviderFor(locationRemoteDataSource) +const locationRemoteDataSourceProvider = LocationRemoteDataSourceProvider._(); + +/// Provides instance of LocationRemoteDataSource + +final class LocationRemoteDataSourceProvider + extends + $FunctionalProvider< + AsyncValue, + LocationRemoteDataSource, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Provides instance of LocationRemoteDataSource + const LocationRemoteDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'locationRemoteDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$locationRemoteDataSourceHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return locationRemoteDataSource(ref); + } +} + +String _$locationRemoteDataSourceHash() => + r'f66b9d96a2c01c00c90a2c8c0414b027d8079e0f'; + +/// Provides instance of LocationLocalDataSource + +@ProviderFor(locationLocalDataSource) +const locationLocalDataSourceProvider = LocationLocalDataSourceProvider._(); + +/// Provides instance of LocationLocalDataSource + +final class LocationLocalDataSourceProvider + extends + $FunctionalProvider< + LocationLocalDataSource, + LocationLocalDataSource, + LocationLocalDataSource + > + with $Provider { + /// Provides instance of LocationLocalDataSource + const LocationLocalDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'locationLocalDataSourceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$locationLocalDataSourceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + LocationLocalDataSource create(Ref ref) { + return locationLocalDataSource(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LocationLocalDataSource value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$locationLocalDataSourceHash() => + r'160b82535ae14c4644b4285243a03335d472f584'; + +/// Provides instance of LocationRepository + +@ProviderFor(locationRepository) +const locationRepositoryProvider = LocationRepositoryProvider._(); + +/// Provides instance of LocationRepository + +final class LocationRepositoryProvider + extends + $FunctionalProvider< + AsyncValue, + LocationRepository, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Provides instance of LocationRepository + const LocationRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'locationRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$locationRepositoryHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return locationRepository(ref); + } +} + +String _$locationRepositoryHash() => + r'7ead096fe90803ecc8ef7c27186a59044c306668'; + +/// Manages list of cities with offline-first approach +/// +/// This is the MAIN provider for cities. +/// Returns list of City entities (cached → API). + +@ProviderFor(Cities) +const citiesProvider = CitiesProvider._(); + +/// Manages list of cities with offline-first approach +/// +/// This is the MAIN provider for cities. +/// Returns list of City entities (cached → API). +final class CitiesProvider extends $AsyncNotifierProvider> { + /// Manages list of cities with offline-first approach + /// + /// This is the MAIN provider for cities. + /// Returns list of City entities (cached → API). + const CitiesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'citiesProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$citiesHash(); + + @$internal + @override + Cities create() => Cities(); +} + +String _$citiesHash() => r'92405067c99ad5e33bd1b4fecd33576baa0c4e2f'; + +/// Manages list of cities with offline-first approach +/// +/// This is the MAIN provider for cities. +/// Returns list of City entities (cached → API). + +abstract class _$Cities extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier>, List>, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// Manages list of wards for a specific city with offline-first approach +/// +/// Uses .family modifier to create a provider per city code. +/// Returns list of Ward entities (cached → API). + +@ProviderFor(wards) +const wardsProvider = WardsFamily._(); + +/// Manages list of wards for a specific city with offline-first approach +/// +/// Uses .family modifier to create a provider per city code. +/// Returns list of Ward entities (cached → API). + +final class WardsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + /// Manages list of wards for a specific city with offline-first approach + /// + /// Uses .family modifier to create a provider per city code. + /// Returns list of Ward entities (cached → API). + const WardsProvider._({ + required WardsFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'wardsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$wardsHash(); + + @override + String toString() { + return r'wardsProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement> $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + final argument = this.argument as String; + return wards(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is WardsProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$wardsHash() => r'7e970ebd13149d6c1d4e76d0ba9f2a9a43cd62fc'; + +/// Manages list of wards for a specific city with offline-first approach +/// +/// Uses .family modifier to create a provider per city code. +/// Returns list of Ward entities (cached → API). + +final class WardsFamily extends $Family + with $FunctionalFamilyOverride>, String> { + const WardsFamily._() + : super( + retry: null, + name: r'wardsProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Manages list of wards for a specific city with offline-first approach + /// + /// Uses .family modifier to create a provider per city code. + /// Returns list of Ward entities (cached → API). + + WardsProvider call(String cityCode) => + WardsProvider._(argument: cityCode, from: this); + + @override + String toString() => r'wardsProvider'; +} + +/// Get city by code + +@ProviderFor(cityByCode) +const cityByCodeProvider = CityByCodeFamily._(); + +/// Get city by code + +final class CityByCodeProvider extends $FunctionalProvider + with $Provider { + /// Get city by code + const CityByCodeProvider._({ + required CityByCodeFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'cityByCodeProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$cityByCodeHash(); + + @override + String toString() { + return r'cityByCodeProvider' + '' + '($argument)'; + } + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + City? create(Ref ref) { + final argument = this.argument as String; + return cityByCode(ref, argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(City? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is CityByCodeProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$cityByCodeHash() => r'dd5e7296f16d6c78beadc28eb97adf5ba06549a5'; + +/// Get city by code + +final class CityByCodeFamily extends $Family + with $FunctionalFamilyOverride { + const CityByCodeFamily._() + : super( + retry: null, + name: r'cityByCodeProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Get city by code + + CityByCodeProvider call(String code) => + CityByCodeProvider._(argument: code, from: this); + + @override + String toString() => r'cityByCodeProvider'; +} + +/// Get cities as map (code → City) for easy lookup + +@ProviderFor(citiesMap) +const citiesMapProvider = CitiesMapProvider._(); + +/// Get cities as map (code → City) for easy lookup + +final class CitiesMapProvider + extends + $FunctionalProvider< + Map, + Map, + Map + > + with $Provider> { + /// Get cities as map (code → City) for easy lookup + const CitiesMapProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'citiesMapProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$citiesMapHash(); + + @$internal + @override + $ProviderElement> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + Map create(Ref ref) { + return citiesMap(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$citiesMapHash() => r'80d684d68276eac20208d977be382004971738fa'; + +/// Get wards as map (code → Ward) for a city + +@ProviderFor(wardsMap) +const wardsMapProvider = WardsMapFamily._(); + +/// Get wards as map (code → Ward) for a city + +final class WardsMapProvider + extends + $FunctionalProvider< + Map, + Map, + Map + > + with $Provider> { + /// Get wards as map (code → Ward) for a city + const WardsMapProvider._({ + required WardsMapFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'wardsMapProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$wardsMapHash(); + + @override + String toString() { + return r'wardsMapProvider' + '' + '($argument)'; + } + + @$internal + @override + $ProviderElement> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + Map create(Ref ref) { + final argument = this.argument as String; + return wardsMap(ref, argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } + + @override + bool operator ==(Object other) { + return other is WardsMapProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$wardsMapHash() => r'977cb8eb6974a46a8dbc6a68bea004dc64dcfbb9'; + +/// Get wards as map (code → Ward) for a city + +final class WardsMapFamily extends $Family + with $FunctionalFamilyOverride, String> { + const WardsMapFamily._() + : super( + retry: null, + name: r'wardsMapProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Get wards as map (code → Ward) for a city + + WardsMapProvider call(String cityCode) => + WardsMapProvider._(argument: cityCode, from: this); + + @override + String toString() => r'wardsMapProvider'; +} diff --git a/lib/features/account/presentation/widgets/address_card.dart b/lib/features/account/presentation/widgets/address_card.dart index 631ace3..f171b35 100644 --- a/lib/features/account/presentation/widgets/address_card.dart +++ b/lib/features/account/presentation/widgets/address_card.dart @@ -42,13 +42,21 @@ class AddressCard extends StatelessWidget { border: isDefault ? Border.all(color: AppColors.primaryBlue, width: 2) : null, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], + boxShadow: isDefault + ? [ + BoxShadow( + color: AppColors.primaryBlue.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -93,21 +101,27 @@ class AddressCard extends StatelessWidget { ), ) else if (onSetDefault != null) - TextButton( - onPressed: onSetDefault, - style: TextButton.styleFrom( + InkWell( + onTap: onSetDefault, + borderRadius: BorderRadius.circular(4), + child: Container( padding: const EdgeInsets.symmetric( horizontal: 8, - vertical: 2, + vertical: 4, ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: const Text( - 'Đặt mặc định', - style: TextStyle( - fontSize: 12, - color: AppColors.primaryBlue, + decoration: BoxDecoration( + border: Border.all( + color: AppColors.primaryBlue.withValues(alpha: 0.3), + ), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Đặt mặc định', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.primaryBlue, + ), ), ), ), @@ -147,20 +161,25 @@ class AddressCard extends StatelessWidget { children: [ // Edit Button if (onEdit != null) - InkWell( - onTap: onEdit, - borderRadius: BorderRadius.circular(8), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE2E8F0)), - borderRadius: BorderRadius.circular(8), - ), - child: const FaIcon( - FontAwesomeIcons.penToSquare, - size: 16, - color: AppColors.primaryBlue, + Material( + color: Colors.transparent, + child: InkWell( + onTap: onEdit, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: FaIcon( + FontAwesomeIcons.penToSquare, + size: 16, + color: AppColors.primaryBlue, + ), + ), ), ), ), @@ -169,20 +188,25 @@ class AddressCard extends StatelessWidget { // Delete Button if (onDelete != null) - InkWell( - onTap: onDelete, - borderRadius: BorderRadius.circular(8), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE2E8F0)), - borderRadius: BorderRadius.circular(8), - ), - child: const FaIcon( - FontAwesomeIcons.trashCan, - size: 16, - color: AppColors.danger, + Material( + color: Colors.transparent, + child: InkWell( + onTap: onDelete, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: FaIcon( + FontAwesomeIcons.trashCan, + size: 16, + color: AppColors.danger, + ), + ), ), ), ), diff --git a/lib/hive_registrar.g.dart b/lib/hive_registrar.g.dart index 11b1f24..e7bfaee 100644 --- a/lib/hive_registrar.g.dart +++ b/lib/hive_registrar.g.dart @@ -5,8 +5,11 @@ import 'package:hive_ce/hive.dart'; import 'package:worker/core/database/models/cached_data.dart'; import 'package:worker/core/database/models/enums.dart'; +import 'package:worker/features/account/data/models/address_model.dart'; import 'package:worker/features/account/data/models/audit_log_model.dart'; +import 'package:worker/features/account/data/models/city_model.dart'; import 'package:worker/features/account/data/models/payment_reminder_model.dart'; +import 'package:worker/features/account/data/models/ward_model.dart'; import 'package:worker/features/auth/data/models/business_unit_model.dart'; import 'package:worker/features/auth/data/models/user_model.dart'; import 'package:worker/features/auth/data/models/user_session_model.dart'; @@ -36,6 +39,7 @@ import 'package:worker/features/showrooms/data/models/showroom_product_model.dar extension HiveRegistrar on HiveInterface { void registerAdapters() { + registerAdapter(AddressModelAdapter()); registerAdapter(AuditLogModelAdapter()); registerAdapter(BusinessUnitModelAdapter()); registerAdapter(CachedDataAdapter()); @@ -43,6 +47,7 @@ extension HiveRegistrar on HiveInterface { registerAdapter(CartModelAdapter()); registerAdapter(CategoryModelAdapter()); registerAdapter(ChatRoomModelAdapter()); + registerAdapter(CityModelAdapter()); registerAdapter(ComplaintStatusAdapter()); registerAdapter(ContentTypeAdapter()); registerAdapter(DesignRequestModelAdapter()); @@ -86,11 +91,13 @@ extension HiveRegistrar on HiveInterface { registerAdapter(UserRoleAdapter()); registerAdapter(UserSessionModelAdapter()); registerAdapter(UserStatusAdapter()); + registerAdapter(WardModelAdapter()); } } extension IsolatedHiveRegistrar on IsolatedHiveInterface { void registerAdapters() { + registerAdapter(AddressModelAdapter()); registerAdapter(AuditLogModelAdapter()); registerAdapter(BusinessUnitModelAdapter()); registerAdapter(CachedDataAdapter()); @@ -98,6 +105,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(CartModelAdapter()); registerAdapter(CategoryModelAdapter()); registerAdapter(ChatRoomModelAdapter()); + registerAdapter(CityModelAdapter()); registerAdapter(ComplaintStatusAdapter()); registerAdapter(ContentTypeAdapter()); registerAdapter(DesignRequestModelAdapter()); @@ -141,5 +149,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(UserRoleAdapter()); registerAdapter(UserSessionModelAdapter()); registerAdapter(UserStatusAdapter()); + registerAdapter(WardModelAdapter()); } } diff --git a/pubspec.lock b/pubspec.lock index 6bec023..7707914 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -369,6 +369,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8357ea3..34280cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: hooks_riverpod: ^3.0.0 flutter_hooks: ^0.21.3+1 riverpod_annotation: ^3.0.0 + equatable: ^2.0.7 # Local Database hive_ce: ^2.6.0