diff --git a/README.md b/README.md index bb12928..4edd9ec 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ Responsive Flutter client for the CellphoneZ marketplace, enabling shoppers to b - After schema edits, run `melos run gen` to refresh generated clients/Freezed models. - Document migrations under `docs/` and coordinate Supabase migrations with backend teammates. - Keep `.env` and other secrets out of source control; leverage the secure vault when sharing configs. +- Map features rely on `MAPTILER_API_KEY` in `.env`; without it the Store Locator/admin maps will show a placeholder instead of loading tiles. --- -Need clarifications or more screenshots? Check `docs/assets/` for the full catalog or open an issue/PR describing the additions. Happy shipping! \ No newline at end of file +Need clarifications or more screenshots? Check `docs/assets/` for the full catalog or open an issue/PR describing the additions. Happy shipping! diff --git a/docs/store_locator_plan.md b/docs/store_locator_plan.md new file mode 100644 index 0000000..2bf5ac2 --- /dev/null +++ b/docs/store_locator_plan.md @@ -0,0 +1,63 @@ +# Store Locator & Map Integration Plan + +## Goals +- Cung cấp trải nghiệm bản đồ khả dụng cho người dùng (chọn điểm nhận hàng) và admin (quản lý cửa hàng). +- Giảm độ phức tạp bằng cách eager-load ≤10 cửa hàng. +- Cho phép chuyển đổi linh hoạt giữa Google Maps / MapTiler cho bước dẫn đường. + +## User-Facing Store Locator +### Entry Points +- **Checkout flow**: Trong `PaymentScreen`, khi user chọn `store_pickup` hiển thị nút “Chọn cửa hàng”. Nhấn vào push `StoreLocatorScreen`. +- **Bottom navigation**: Thêm tab hoặc shortcut “Cửa hàng” để người dùng truy cập map ngay từ trang chủ. + +### Data & Schema +- Bảng mới `public.stores`: + - `id UUID PK` + - `name`, `address_full`, `city` + - `latitude`, `longitude` + - `phone`, `services JSONB`, `opening_hours JSONB` + - `is_active boolean` +- Bảng `orders` bổ sung: + - `pickup_type text CHECK (IN ('delivery','store_pickup')) DEFAULT 'delivery'` + - `pickup_store_id uuid REFERENCES public.stores(id)` +- Seed tối đa 10 cửa hàng mẫu để eager-load. + +### API Strategy +- Eager load tất cả cửa hàng thông qua PostgREST endpoint `GET /rest/v1/stores?is_active=eq.true`. +- Lọc, sắp xếp theo khoảng cách/keyword ở client (Haversine). +- Giữ khả năng mở rộng sang RPC `search_stores` nếu số lượng tăng. + +### Client Architecture +- `StoreService.fetchStores()` trả `List`; cache trong bộ nhớ. +- `StoreLocatorCubit` xử lý trạng thái: danh sách đầy đủ, lọc, vị trí người dùng, store được chọn. +- `ExternalNavigationService` chịu trách nhiệm mở ứng dụng điều hướng với enum `ExternalMapProvider` (Google Maps hoặc MapTiler Directions). + +### StoreLocatorScreen (User) +- AppBar với TextField tìm kiếm (không voice). +- `flutter_map` + MapTiler tiles hiển thị marker cửa hàng; overlay `MapTilerLogo`/copyright. +- Bottom sheet với danh sách & filter chips (dịch vụ, “đang mở”, bán kính slider). +- Marker ↔ card đồng bộ; nút “Chọn làm điểm lấy” pop `StoreSelection` về caller qua `Navigator.pop`. +- Tùy chọn “Chỉ đường” sử dụng `ExternalNavigationService`. + +### Checkout Integration +- `PaymentScreen` nhận `StoreSelection`, lưu `_selectedStore`, cập nhật UI và set `_selectedMethod = 'store_pickup'`. +- Order payload gửi `pickup_type='store_pickup'` + `pickup_store_id`. + +## Admin Store Management +- Màn hình admin mới (`lib/admin/store_manager/`): + - Danh sách cửa hàng (status toggle, edit/delete). + - MapTiler map song song hiển thị marker; chọn marker highlight entry. + - Form tạo/sửa cho phép chọn tọa độ bằng map tap hoặc nhập tay; upload metadata (dịch vụ, giờ mở cửa, hình ảnh). + - Action “Preview trên user map”. +- Shared `StoreService` với endpoint có quyền admin (RLS policies). + +## Attribution & Branding +- Overlay logo hoặc text “© MapTiler © OpenStreetMap contributors” ở góc map để tuân thủ licensing. +- Ghi chú thêm trong docs/README cách cấu hình `.env` (`MAPTILER_API_KEY`) & MapTiler style. + +## Testing & Rollout +- Unit tests cho `StoreLocatorCubit` (lọc, chọn store, error states). +- Widget test cho `StoreLocatorScreen` (search/filter interactions). +- Admin CRUD tests (mock Supabase). +- Manual QA script: mở tab “Cửa hàng”, tìm/lọc, chọn store, quay lại checkout, đặt đơn. +- Document các bước seed dữ liệu và phân quyền Supabase. diff --git a/lib/admin/stores/view/store_management_screen.dart b/lib/admin/stores/view/store_management_screen.dart new file mode 100644 index 0000000..07e592f --- /dev/null +++ b/lib/admin/stores/view/store_management_screen.dart @@ -0,0 +1,846 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' as latlng; + +import '../../../components/map_attribution_badge.dart'; +import '../../../constants.dart'; +import '../../../models/store.dart'; +import '../../../services/map_style.dart'; +import '../../../services/store_service.dart'; + +class StoreManagementScreen extends StatefulWidget { + const StoreManagementScreen({super.key}); + + @override + State createState() => _StoreManagementScreenState(); +} + +class _StoreManagementScreenState extends State { + final StoreService _storeService = StoreService(); + final MapController _mapController = MapController(); + final TextEditingController _searchController = TextEditingController(); + + bool _isLoading = true; + List _stores = []; + Store? _selectedStore; + latlng.LatLng? _pendingCoordinate; + + @override + void initState() { + super.initState(); + _loadStores(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadStores() async { + setState(() => _isLoading = true); + try { + final stores = await _storeService.fetchStores(forceRefresh: true); + setState(() { + _stores = stores; + _isLoading = false; + }); + } catch (error) { + setState(() => _isLoading = false); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Không thể tải danh sách cửa hàng: $error')), + ); + } + } + + List get _filteredStores { + final query = _searchController.text.trim().toLowerCase(); + if (query.isEmpty) { + return _stores; + } + return _stores.where((store) { + return store.name.toLowerCase().contains(query) || + (store.city ?? '').toLowerCase().contains(query) || + store.addressFull.toLowerCase().contains(query); + }).toList(); + } + + Future _toggleStore(Store store, bool isActive) async { + try { + await _storeService.setStoreActive(store.id, isActive); + await _loadStores(); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Không thể cập nhật trạng thái: $error')), + ); + } + } + + Future _deleteStore(Store store) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Xoá cửa hàng'), + content: Text('Bạn có chắc muốn xoá "${store.name}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Huỷ'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: cellphoneZRed), + child: const Text('Xoá'), + ), + ], + ), + ); + + if (confirm != true) return; + + try { + await _storeService.deleteStore(store.id); + await _loadStores(); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Không thể xoá cửa hàng: $error')), + ); + } + } + + Future _openStoreForm({Store? store}) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _StoreFormSheet( + store: store, + pendingCoordinate: _pendingCoordinate, + onSubmit: (payload) async { + if (store == null) { + await _storeService.createStore( + name: payload.name, + addressFull: payload.address, + latitude: payload.latitude, + longitude: payload.longitude, + city: payload.city, + phone: payload.phone, + services: payload.services, + openingHours: payload.openingHours, + isActive: payload.isActive, + ); + } else { + await _storeService.updateStore( + store, + name: payload.name, + addressFull: payload.address, + latitude: payload.latitude, + longitude: payload.longitude, + city: payload.city, + phone: payload.phone, + services: payload.services, + openingHours: payload.openingHours, + isActive: payload.isActive, + ); + } + }, + ), + ); + + if (result == true) { + await _loadStores(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: const Text('Quản lý cửa hàng'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadStores, + ), + ], + ), + body: Stack( + children: [ + Positioned.fill(child: _buildMap(context)), + _buildTopControls(context), + _buildBottomSheet(context), + if (_isLoading) + Container( + color: Colors.black12, + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } + + Widget _buildTopControls(BuildContext context) { + final mediaTop = MediaQuery.of(context).padding.top; + final double topPadding = mediaTop; + return Positioned( + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB(16, topPadding, 16, 0), + child: _AdminSearchBar( + controller: _searchController, + onChanged: (_) => setState(() {}), + onClear: () { + _searchController.clear(); + setState(() {}); + }, + onAdd: () => _openStoreForm(), + onRefresh: _loadStores, + ), + ), + ), + ); + } + + Widget _buildBottomSheet(BuildContext context) { + return DraggableScrollableSheet( + minChildSize: 0.25, + initialChildSize: 0.3, + maxChildSize: 0.9, + builder: (context, scrollController) { + final stores = _filteredStores; + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 20, + offset: const Offset(0, -4), + ), + ], + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + Expanded( + child: stores.isEmpty + ? const Center(child: Text('Không có cửa hàng nào')) + : RefreshIndicator( + onRefresh: _loadStores, + child: ListView.separated( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + itemCount: stores.length, + separatorBuilder: (_, __) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final store = stores[index]; + return _StoreCard( + store: store, + isSelected: _selectedStore?.id == store.id, + onSelect: () => _onSelectStore(store), + onEdit: () => _openStoreForm(store: store), + onDelete: () => _deleteStore(store), + onToggleActive: (value) => + _toggleStore(store, value), + ); + }, + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildMap(BuildContext context) { + if (!MapStyleConfig.hasValidKey) { + return Container( + color: Colors.grey.shade300, + child: const Center( + child: Text('Chưa cấu hình MAPTILER_API_KEY'), + ), + ); + } + + final markers = [ + for (final store in _stores) + Marker( + width: 42, + height: 42, + point: latlng.LatLng(store.latitude, store.longitude), + child: GestureDetector( + onTap: () => _onSelectStore(store), + child: Icon( + Icons.location_on, + color: + _selectedStore?.id == store.id ? cellphoneZRed : Colors.blue, + size: _selectedStore?.id == store.id ? 40 : 30, + ), + ), + ), + if (_pendingCoordinate != null) + Marker( + width: 32, + height: 32, + point: _pendingCoordinate!, + child: const Icon( + Icons.push_pin, + color: Colors.indigo, + size: 28, + ), + ), + ]; + + final latlng.LatLng center; + if (_selectedStore != null) { + center = + latlng.LatLng(_selectedStore!.latitude, _selectedStore!.longitude); + } else if (_stores.isNotEmpty) { + center = latlng.LatLng(_stores.first.latitude, _stores.first.longitude); + } else { + center = latlng.LatLng(10.762622, 106.660172); + } + + return Stack( + children: [ + FlutterMap( + mapController: _mapController, + options: MapOptions( + center: center, + zoom: 12, + interactiveFlags: InteractiveFlag.all, + onTap: (tapPosition, point) => _handleMapTap(context, point), + ), + children: [ + TileLayer( + urlTemplate: MapStyleConfig.rasterTileUrl(), + userAgentPackageName: 'com.cellphonez.admin', + ), + MarkerLayer(markers: markers), + ], + ), + const Positioned( + bottom: 16, + right: 16, + child: MapAttributionBadge(), + ), + ], + ); + } + + void _handleMapTap(BuildContext context, latlng.LatLng point) { + setState(() { + _pendingCoordinate = point; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Đã chọn tọa độ (${point.latitude.toStringAsFixed(5)}, ' + '${point.longitude.toStringAsFixed(5)}) cho cửa hàng mới', + ), + duration: const Duration(seconds: 2), + ), + ); + } + + void _onSelectStore(Store store) { + setState(() { + _selectedStore = store; + }); + _mapController.move( + latlng.LatLng(store.latitude, store.longitude), + 15, + ); + } +} + +class _AdminSearchBar extends StatelessWidget { + const _AdminSearchBar({ + required this.controller, + required this.onChanged, + required this.onClear, + required this.onAdd, + required this.onRefresh, + }); + + final TextEditingController controller; + final ValueChanged onChanged; + final VoidCallback onClear; + final VoidCallback onAdd; + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + return Material( + elevation: 6, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + hintText: 'Tìm kiếm cửa hàng...', + filled: true, + fillColor: Colors.white, + prefixIcon: const Icon(Icons.search), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: onClear, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + ), + ), + ), + const SizedBox(width: 8), + _RoundIconButton( + icon: Icons.add_business, + tooltip: 'Thêm cửa hàng', + onTap: onAdd, + ), + const SizedBox(width: 8), + _RoundIconButton( + icon: Icons.refresh, + tooltip: 'Làm mới', + onTap: onRefresh, + ), + ], + ), + ), + ); + } +} + +class _RoundIconButton extends StatelessWidget { + const _RoundIconButton({ + required this.icon, + required this.onTap, + this.tooltip, + }); + + final IconData icon; + final VoidCallback onTap; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final button = Material( + color: Colors.white, + shape: const CircleBorder(), + elevation: 3, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: SizedBox( + width: 44, + height: 44, + child: Icon(icon, color: cellphoneZRed), + ), + ), + ); + + return tooltip != null ? Tooltip(message: tooltip!, child: button) : button; + } +} + +class _StoreCard extends StatelessWidget { + const _StoreCard({ + required this.store, + required this.isSelected, + required this.onSelect, + required this.onEdit, + required this.onDelete, + required this.onToggleActive, + }); + + final Store store; + final bool isSelected; + final VoidCallback onSelect; + final VoidCallback onEdit; + final VoidCallback onDelete; + final ValueChanged onToggleActive; + + @override + Widget build(BuildContext context) { + return Card( + elevation: isSelected ? 4 : 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: isSelected ? cellphoneZRed : Colors.grey.shade200, + ), + ), + child: InkWell( + onTap: onSelect, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + store.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + Switch( + value: store.isActive, + onChanged: onToggleActive, + ), + ], + ), + const SizedBox(height: 4), + Text(store.addressFull), + if (store.city != null) Text('Thành phố: ${store.city}'), + if (store.phone != null) Text('Điện thoại: ${store.phone}'), + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: store.services + .map( + (service) => Chip( + label: Text(service), + backgroundColor: Colors.grey.shade100, + ), + ) + .toList(), + ), + const SizedBox(height: 12), + Row( + children: [ + TextButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit), + label: const Text('Chỉnh sửa'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline), + label: const Text('Xoá'), + style: TextButton.styleFrom( + foregroundColor: Colors.redAccent, + ), + ), + ], + ) + ], + ), + ), + ), + ); + } +} + +class _StoreFormPayload { + _StoreFormPayload({ + required this.name, + required this.address, + required this.latitude, + required this.longitude, + this.city, + this.phone, + this.services = const [], + this.openingHours, + this.isActive = true, + }); + + final String name; + final String address; + final double latitude; + final double longitude; + final String? city; + final String? phone; + final List services; + final Map? openingHours; + final bool isActive; +} + +class _StoreFormSheet extends StatefulWidget { + const _StoreFormSheet({ + required this.onSubmit, + this.store, + this.pendingCoordinate, + }); + + final Store? store; + final latlng.LatLng? pendingCoordinate; + final Future Function(_StoreFormPayload payload) onSubmit; + + @override + State<_StoreFormSheet> createState() => _StoreFormSheetState(); +} + +class _StoreFormSheetState extends State<_StoreFormSheet> { + final _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final TextEditingController _addressController; + late final TextEditingController _cityController; + late final TextEditingController _phoneController; + late final TextEditingController _latitudeController; + late final TextEditingController _longitudeController; + late final TextEditingController _servicesController; + late final TextEditingController _openingHoursController; + bool _isActive = true; + bool _submitting = false; + + @override + void initState() { + super.initState(); + final store = widget.store; + _nameController = TextEditingController(text: store?.name ?? ''); + _addressController = + TextEditingController(text: store?.addressFull ?? ''); + _cityController = TextEditingController(text: store?.city ?? ''); + _phoneController = TextEditingController(text: store?.phone ?? ''); + _latitudeController = TextEditingController( + text: (store?.latitude ?? + widget.pendingCoordinate?.latitude ?? + 10.762622) + .toString(), + ); + _longitudeController = TextEditingController( + text: (store?.longitude ?? + widget.pendingCoordinate?.longitude ?? + 106.660172) + .toString(), + ); + _servicesController = TextEditingController( + text: store?.services.join(', ') ?? '', + ); + _openingHoursController = TextEditingController( + text: store?.openingHours != null + ? const JsonEncoder.withIndent(' ').convert(store!.openingHours) + : '', + ); + _isActive = store?.isActive ?? true; + } + + @override + void dispose() { + _nameController.dispose(); + _addressController.dispose(); + _cityController.dispose(); + _phoneController.dispose(); + _latitudeController.dispose(); + _longitudeController.dispose(); + _servicesController.dispose(); + _openingHoursController.dispose(); + super.dispose(); + } + + Future _handleSubmit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _submitting = true); + + try { + final services = _servicesController.text + .split(',') + .map((e) => e.trim()) + .where((element) => element.isNotEmpty) + .toList(); + + Map? openingHours; + if (_openingHoursController.text.trim().isNotEmpty) { + openingHours = jsonDecode(_openingHoursController.text.trim()) + as Map; + } + + await widget.onSubmit( + _StoreFormPayload( + name: _nameController.text.trim(), + address: _addressController.text.trim(), + city: _cityController.text.trim().isEmpty + ? null + : _cityController.text.trim(), + phone: _phoneController.text.trim().isEmpty + ? null + : _phoneController.text.trim(), + latitude: double.parse(_latitudeController.text.trim()), + longitude: double.parse(_longitudeController.text.trim()), + services: services, + openingHours: openingHours, + isActive: _isActive, + ), + ); + if (mounted) { + Navigator.pop(context, true); + } + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Không thể lưu cửa hàng: $error')), + ); + } finally { + if (mounted) { + setState(() => _submitting = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.store == null ? 'Thêm cửa hàng' : 'Chỉnh sửa cửa hàng', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Tên cửa hàng', + ), + validator: (value) => + value == null || value.trim().isEmpty ? 'Bắt buộc' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _addressController, + decoration: const InputDecoration(labelText: 'Địa chỉ'), + validator: (value) => + value == null || value.trim().isEmpty ? 'Bắt buộc' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _cityController, + decoration: const InputDecoration(labelText: 'Thành phố'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _phoneController, + decoration: const InputDecoration(labelText: 'Số điện thoại'), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _latitudeController, + decoration: const InputDecoration(labelText: 'Vĩ độ'), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (value) => + double.tryParse(value ?? '') == null + ? 'Sai định dạng' + : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _longitudeController, + decoration: const InputDecoration(labelText: 'Kinh độ'), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (value) => + double.tryParse(value ?? '') == null + ? 'Sai định dạng' + : null, + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: _servicesController, + decoration: const InputDecoration( + labelText: 'Dịch vụ (phân cách bằng dấu phẩy)', + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _openingHoursController, + decoration: const InputDecoration( + labelText: 'Opening hours (JSON)', + ), + maxLines: 4, + ), + const SizedBox(height: 12), + SwitchListTile( + value: _isActive, + onChanged: (value) => setState(() => _isActive = value), + title: const Text('Đang hoạt động'), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _submitting ? null : _handleSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: cellphoneZRed, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(widget.store == null ? 'Thêm mới' : 'Lưu thay đổi'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/admin/view/admin_main_screen.dart b/lib/admin/view/admin_main_screen.dart index cbec7ea..eb0823c 100644 --- a/lib/admin/view/admin_main_screen.dart +++ b/lib/admin/view/admin_main_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:shop/admin/category/view/category_management.dart'; import 'package:shop/admin/orders/view/order_management.dart'; import 'package:shop/admin/products/view/product_list_screen.dart'; +import 'package:shop/admin/stores/view/store_management_screen.dart'; import 'package:shop/constants.dart'; import 'package:shop/screens/profile/views/profile_screen.dart'; @@ -31,6 +32,10 @@ class _AdminMainScreenState extends State { icon: Icons.category_outlined, label: 'Danh mục', ), + _AdminNavItem( + icon: Icons.store_mall_directory_outlined, + label: 'Cửa hàng', + ), _AdminNavItem( icon: Icons.receipt_long_outlined, label: 'Đơn hàng', @@ -47,6 +52,7 @@ class _AdminMainScreenState extends State { _screens = const [ ProductListScreen(), CategoryManagement(), + StoreManagementScreen(), OrderManagement(), ProfileScreen(), ]; diff --git a/lib/components/map_attribution_badge.dart b/lib/components/map_attribution_badge.dart new file mode 100644 index 0000000..959a7ed --- /dev/null +++ b/lib/components/map_attribution_badge.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import '../services/map_style.dart'; + +class MapAttributionBadge extends StatelessWidget { + const MapAttributionBadge({super.key}); + + @override + Widget build(BuildContext context) { + return Material( + elevation: 1, + borderRadius: BorderRadius.circular(12), + color: Colors.black.withOpacity(0.6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + MapStyleConfig.attribution, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ), + ); + } +} diff --git a/lib/entry_point.dart b/lib/entry_point.dart index df8e23b..b4013d4 100644 --- a/lib/entry_point.dart +++ b/lib/entry_point.dart @@ -3,6 +3,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:shop/components/app_animated_switcher.dart'; import 'package:shop/constants.dart'; import 'package:shop/route/screen_export.dart'; +import 'package:shop/screens/store_locator/views/store_locator_screen.dart'; class EntryPoint extends StatefulWidget { const EntryPoint({super.key}); @@ -16,6 +17,10 @@ class _EntryPointState extends State { HomeScreen(), DiscoverScreen(), // EmptyCartScreen(), // if Cart is empty + StoreLocatorScreen( + enableSelection: false, + title: 'Cửa hàng CellphoneZ', + ), CartScreen(), ProfileScreen(), ]; @@ -116,6 +121,12 @@ class _EntryPointState extends State { svgIcon("assets/icons/Category.svg", color: primaryColor), label: "Discover", ), + BottomNavigationBarItem( + icon: svgIcon("assets/icons/Stores.svg"), + activeIcon: + svgIcon("assets/icons/Stores.svg", color: primaryColor), + label: "Stores", + ), BottomNavigationBarItem( icon: svgIcon("assets/icons/Bag.svg"), activeIcon: svgIcon("assets/icons/Bag.svg", color: primaryColor), diff --git a/lib/models/store.dart b/lib/models/store.dart new file mode 100644 index 0000000..bfeef28 --- /dev/null +++ b/lib/models/store.dart @@ -0,0 +1,25 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'store.freezed.dart'; +part 'store.g.dart'; + +@freezed +class Store with _$Store { + const factory Store({ + required String id, + required String name, + @JsonKey(name: 'address_full') required String addressFull, + String? city, + required double latitude, + required double longitude, + String? phone, + @JsonKey(defaultValue: []) @Default([]) List services, + @JsonKey(name: 'opening_hours') Map? openingHours, + @JsonKey(name: 'is_active') @Default(true) bool isActive, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, + @JsonKey(ignore: true) double? distanceKm, + }) = _Store; + + factory Store.fromJson(Map json) => _$StoreFromJson(json); +} diff --git a/lib/models/store.freezed.dart b/lib/models/store.freezed.dart new file mode 100644 index 0000000..80cd503 --- /dev/null +++ b/lib/models/store.freezed.dart @@ -0,0 +1,464 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'store.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +Store _$StoreFromJson(Map json) { + return _Store.fromJson(json); +} + +/// @nodoc +mixin _$Store { + String get id => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + @JsonKey(name: 'address_full') + String get addressFull => throw _privateConstructorUsedError; + String? get city => throw _privateConstructorUsedError; + double get latitude => throw _privateConstructorUsedError; + double get longitude => throw _privateConstructorUsedError; + String? get phone => throw _privateConstructorUsedError; + @JsonKey(defaultValue: []) + List get services => throw _privateConstructorUsedError; + @JsonKey(name: 'opening_hours') + Map? get openingHours => throw _privateConstructorUsedError; + @JsonKey(name: 'is_active') + bool get isActive => throw _privateConstructorUsedError; + @JsonKey(name: 'created_at') + DateTime? get createdAt => throw _privateConstructorUsedError; + @JsonKey(name: 'updated_at') + DateTime? get updatedAt => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + double? get distanceKm => throw _privateConstructorUsedError; + + /// Serializes this Store to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Store + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $StoreCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $StoreCopyWith<$Res> { + factory $StoreCopyWith(Store value, $Res Function(Store) then) = + _$StoreCopyWithImpl<$Res, Store>; + @useResult + $Res call( + {String id, + String name, + @JsonKey(name: 'address_full') String addressFull, + String? city, + double latitude, + double longitude, + String? phone, + @JsonKey(defaultValue: []) List services, + @JsonKey(name: 'opening_hours') Map? openingHours, + @JsonKey(name: 'is_active') bool isActive, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, + @JsonKey(ignore: true) double? distanceKm}); +} + +/// @nodoc +class _$StoreCopyWithImpl<$Res, $Val extends Store> + implements $StoreCopyWith<$Res> { + _$StoreCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Store + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? addressFull = null, + Object? city = freezed, + Object? latitude = null, + Object? longitude = null, + Object? phone = freezed, + Object? services = null, + Object? openingHours = freezed, + Object? isActive = null, + Object? createdAt = freezed, + Object? updatedAt = freezed, + Object? distanceKm = freezed, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + addressFull: null == addressFull + ? _value.addressFull + : addressFull // ignore: cast_nullable_to_non_nullable + as String, + city: freezed == city + ? _value.city + : city // ignore: cast_nullable_to_non_nullable + as String?, + latitude: null == latitude + ? _value.latitude + : latitude // ignore: cast_nullable_to_non_nullable + as double, + longitude: null == longitude + ? _value.longitude + : longitude // ignore: cast_nullable_to_non_nullable + as double, + phone: freezed == phone + ? _value.phone + : phone // ignore: cast_nullable_to_non_nullable + as String?, + services: null == services + ? _value.services + : services // ignore: cast_nullable_to_non_nullable + as List, + openingHours: freezed == openingHours + ? _value.openingHours + : openingHours // ignore: cast_nullable_to_non_nullable + as Map?, + isActive: null == isActive + ? _value.isActive + : isActive // ignore: cast_nullable_to_non_nullable + as bool, + createdAt: freezed == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + distanceKm: freezed == distanceKm + ? _value.distanceKm + : distanceKm // ignore: cast_nullable_to_non_nullable + as double?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$StoreImplCopyWith<$Res> implements $StoreCopyWith<$Res> { + factory _$$StoreImplCopyWith( + _$StoreImpl value, $Res Function(_$StoreImpl) then) = + __$$StoreImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String id, + String name, + @JsonKey(name: 'address_full') String addressFull, + String? city, + double latitude, + double longitude, + String? phone, + @JsonKey(defaultValue: []) List services, + @JsonKey(name: 'opening_hours') Map? openingHours, + @JsonKey(name: 'is_active') bool isActive, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, + @JsonKey(ignore: true) double? distanceKm}); +} + +/// @nodoc +class __$$StoreImplCopyWithImpl<$Res> + extends _$StoreCopyWithImpl<$Res, _$StoreImpl> + implements _$$StoreImplCopyWith<$Res> { + __$$StoreImplCopyWithImpl( + _$StoreImpl _value, $Res Function(_$StoreImpl) _then) + : super(_value, _then); + + /// Create a copy of Store + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? addressFull = null, + Object? city = freezed, + Object? latitude = null, + Object? longitude = null, + Object? phone = freezed, + Object? services = null, + Object? openingHours = freezed, + Object? isActive = null, + Object? createdAt = freezed, + Object? updatedAt = freezed, + Object? distanceKm = freezed, + }) { + return _then(_$StoreImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + addressFull: null == addressFull + ? _value.addressFull + : addressFull // ignore: cast_nullable_to_non_nullable + as String, + city: freezed == city + ? _value.city + : city // ignore: cast_nullable_to_non_nullable + as String?, + latitude: null == latitude + ? _value.latitude + : latitude // ignore: cast_nullable_to_non_nullable + as double, + longitude: null == longitude + ? _value.longitude + : longitude // ignore: cast_nullable_to_non_nullable + as double, + phone: freezed == phone + ? _value.phone + : phone // ignore: cast_nullable_to_non_nullable + as String?, + services: null == services + ? _value._services + : services // ignore: cast_nullable_to_non_nullable + as List, + openingHours: freezed == openingHours + ? _value._openingHours + : openingHours // ignore: cast_nullable_to_non_nullable + as Map?, + isActive: null == isActive + ? _value.isActive + : isActive // ignore: cast_nullable_to_non_nullable + as bool, + createdAt: freezed == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + distanceKm: freezed == distanceKm + ? _value.distanceKm + : distanceKm // ignore: cast_nullable_to_non_nullable + as double?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$StoreImpl implements _Store { + const _$StoreImpl( + {required this.id, + required this.name, + @JsonKey(name: 'address_full') required this.addressFull, + this.city, + required this.latitude, + required this.longitude, + this.phone, + @JsonKey(defaultValue: []) + final List services = const [], + @JsonKey(name: 'opening_hours') final Map? openingHours, + @JsonKey(name: 'is_active') this.isActive = true, + @JsonKey(name: 'created_at') this.createdAt, + @JsonKey(name: 'updated_at') this.updatedAt, + @JsonKey(ignore: true) this.distanceKm}) + : _services = services, + _openingHours = openingHours; + + factory _$StoreImpl.fromJson(Map json) => + _$$StoreImplFromJson(json); + + @override + final String id; + @override + final String name; + @override + @JsonKey(name: 'address_full') + final String addressFull; + @override + final String? city; + @override + final double latitude; + @override + final double longitude; + @override + final String? phone; + final List _services; + @override + @JsonKey(defaultValue: []) + List get services { + if (_services is EqualUnmodifiableListView) return _services; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_services); + } + + final Map? _openingHours; + @override + @JsonKey(name: 'opening_hours') + Map? get openingHours { + final value = _openingHours; + if (value == null) return null; + if (_openingHours is EqualUnmodifiableMapView) return _openingHours; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + @JsonKey(name: 'is_active') + final bool isActive; + @override + @JsonKey(name: 'created_at') + final DateTime? createdAt; + @override + @JsonKey(name: 'updated_at') + final DateTime? updatedAt; + @override + @JsonKey(ignore: true) + final double? distanceKm; + + @override + String toString() { + return 'Store(id: $id, name: $name, addressFull: $addressFull, city: $city, latitude: $latitude, longitude: $longitude, phone: $phone, services: $services, openingHours: $openingHours, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt, distanceKm: $distanceKm)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$StoreImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.addressFull, addressFull) || + other.addressFull == addressFull) && + (identical(other.city, city) || other.city == city) && + (identical(other.latitude, latitude) || + other.latitude == latitude) && + (identical(other.longitude, longitude) || + other.longitude == longitude) && + (identical(other.phone, phone) || other.phone == phone) && + const DeepCollectionEquality().equals(other._services, _services) && + const DeepCollectionEquality() + .equals(other._openingHours, _openingHours) && + (identical(other.isActive, isActive) || + other.isActive == isActive) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || + other.updatedAt == updatedAt) && + (identical(other.distanceKm, distanceKm) || + other.distanceKm == distanceKm)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + name, + addressFull, + city, + latitude, + longitude, + phone, + const DeepCollectionEquality().hash(_services), + const DeepCollectionEquality().hash(_openingHours), + isActive, + createdAt, + updatedAt, + distanceKm); + + /// Create a copy of Store + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$StoreImplCopyWith<_$StoreImpl> get copyWith => + __$$StoreImplCopyWithImpl<_$StoreImpl>(this, _$identity); + + @override + Map toJson() { + return _$$StoreImplToJson( + this, + ); + } +} + +abstract class _Store implements Store { + const factory _Store( + {required final String id, + required final String name, + @JsonKey(name: 'address_full') required final String addressFull, + final String? city, + required final double latitude, + required final double longitude, + final String? phone, + @JsonKey(defaultValue: []) final List services, + @JsonKey(name: 'opening_hours') final Map? openingHours, + @JsonKey(name: 'is_active') final bool isActive, + @JsonKey(name: 'created_at') final DateTime? createdAt, + @JsonKey(name: 'updated_at') final DateTime? updatedAt, + @JsonKey(ignore: true) final double? distanceKm}) = _$StoreImpl; + + factory _Store.fromJson(Map json) = _$StoreImpl.fromJson; + + @override + String get id; + @override + String get name; + @override + @JsonKey(name: 'address_full') + String get addressFull; + @override + String? get city; + @override + double get latitude; + @override + double get longitude; + @override + String? get phone; + @override + @JsonKey(defaultValue: []) + List get services; + @override + @JsonKey(name: 'opening_hours') + Map? get openingHours; + @override + @JsonKey(name: 'is_active') + bool get isActive; + @override + @JsonKey(name: 'created_at') + DateTime? get createdAt; + @override + @JsonKey(name: 'updated_at') + DateTime? get updatedAt; + @override + @JsonKey(ignore: true) + double? get distanceKm; + + /// Create a copy of Store + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$StoreImplCopyWith<_$StoreImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/store.g.dart b/lib/models/store.g.dart new file mode 100644 index 0000000..dd3e501 --- /dev/null +++ b/lib/models/store.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'store.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$StoreImpl _$$StoreImplFromJson(Map json) => _$StoreImpl( + id: json['id'] as String, + name: json['name'] as String, + addressFull: json['address_full'] as String, + city: json['city'] as String?, + latitude: (json['latitude'] as num).toDouble(), + longitude: (json['longitude'] as num).toDouble(), + phone: json['phone'] as String?, + services: (json['services'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + openingHours: json['opening_hours'] as Map?, + isActive: json['is_active'] as bool? ?? true, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + ); + +Map _$$StoreImplToJson(_$StoreImpl instance) => + { + 'id': instance.id, + 'name': instance.name, + 'address_full': instance.addressFull, + 'city': instance.city, + 'latitude': instance.latitude, + 'longitude': instance.longitude, + 'phone': instance.phone, + 'services': instance.services, + 'opening_hours': instance.openingHours, + 'is_active': instance.isActive, + 'created_at': instance.createdAt?.toIso8601String(), + 'updated_at': instance.updatedAt?.toIso8601String(), + }; diff --git a/lib/repository/schema_accessor.dart b/lib/repository/schema_accessor.dart index 2f9751d..cb73445 100644 --- a/lib/repository/schema_accessor.dart +++ b/lib/repository/schema_accessor.dart @@ -26,5 +26,6 @@ mixin SchemaAccessor { OrdersTable get ordersSchema => DatabaseSchema.orders; OrderItemsTable get orderItemsSchema => DatabaseSchema.orderItems; PaymentsTable get paymentsSchema => DatabaseSchema.payments; + StoresTable get storesSchema => DatabaseSchema.stores; StorageBuckets get storageBuckets => DatabaseSchema.storage; } diff --git a/lib/repository/store_repository.dart b/lib/repository/store_repository.dart new file mode 100644 index 0000000..b1e018e --- /dev/null +++ b/lib/repository/store_repository.dart @@ -0,0 +1,124 @@ +import '../common/app_logger.dart'; +import '../models/store.dart'; +import '../services/database_schema.dart'; +import 'base_repository.dart'; + +class StoreRepository extends BaseRepository { + StoreRepository({AppLogger? logger}) + : _logger = logger ?? AppLogger.instance; + + final AppLogger _logger; + + @override + String get tableName => storesSchema.table; + + Future> getStores({bool? isActive}) async { + try { + var builder = queryBuilder.select(); + if (isActive != null) { + builder = builder.eq(storesSchema.isActive, isActive); + } + final response = await builder.order(storesSchema.name, ascending: true); + return _parseStores(response); + } catch (error, stackTrace) { + _logger.e('Failed to fetch stores', error, stackTrace); + throw Exception('Failed to fetch stores: $error'); + } + } + + Future createStore(StoreDraft draft) async { + try { + final payload = draft.toPayload(); + final response = await create(payload); + return Store.fromJson(response); + } catch (error, stackTrace) { + _logger.e('Failed to create store', error, stackTrace); + throw Exception('Failed to create store: $error'); + } + } + + Future updateStore(String id, StoreDraft draft) async { + try { + final payload = draft.toPayload(); + final response = await update(id, payload); + return Store.fromJson(response); + } catch (error, stackTrace) { + _logger.e('Failed to update store $id', error, stackTrace); + throw Exception('Failed to update store: $error'); + } + } + + Future updateStoreStatus(String id, bool isActive) async { + try { + final response = await update(id, { + storesSchema.isActive: isActive, + }); + return Store.fromJson(response); + } catch (error, stackTrace) { + _logger.e('Failed to update store status for $id', error, stackTrace); + throw Exception('Failed to update store status: $error'); + } + } + + Future deleteStore(String id) async { + try { + await delete(id); + } catch (error, stackTrace) { + _logger.e('Failed to delete store $id', error, stackTrace); + throw Exception('Failed to delete store: $error'); + } + } + + List _parseStores(dynamic response) { + final data = List>.from(response as List); + return data.map(Store.fromJson).toList(); + } +} + +class StoreDraft { + StoreDraft({ + this.name, + this.addressFull, + this.city, + this.latitude, + this.longitude, + this.phone, + this.services, + this.openingHours, + this.isActive, + }); + + final String? name; + final String? addressFull; + final String? city; + final double? latitude; + final double? longitude; + final String? phone; + final List? services; + final Map? openingHours; + final bool? isActive; + + static const _schema = StoresTable(); + + Map toPayload() { + final payload = {}; + + void setField(String key, dynamic value) { + if (value != null) { + payload[key] = value; + } + } + + setField(_schema.name, name); + setField(_schema.addressFull, addressFull); + setField(_schema.city, city); + setField(_schema.latitude, latitude); + setField(_schema.longitude, longitude); + setField(_schema.phone, phone); + setField(_schema.services, services); + setField(_schema.openingHours, openingHours); + setField(_schema.isActive, isActive); + + return payload; + } +} diff --git a/lib/route/route_constants.dart b/lib/route/route_constants.dart index 668878f..b3e702a 100644 --- a/lib/route/route_constants.dart +++ b/lib/route/route_constants.dart @@ -47,7 +47,9 @@ const String addNewCardScreenRoute = "add_new_card"; const String thanksForOrderScreenRoute = "thanks_order"; const String paymentScreenRoute = "payment"; const String paymentResultScreenRoute = "payment_result"; +const String storeLocatorScreenRoute = "store_locator"; const String adminProductListScreenRoute = "admin_product_list"; const String categoryManagementScreenRoute = "category_management"; +const String adminStoreManagementScreenRoute = "admin_store_management"; const String orderConfirmationScreenRoute = "order_confirmation"; const String orderHistoryScreenRoute = "order_history"; diff --git a/lib/route/router.dart b/lib/route/router.dart index fa1d278..f38101c 100644 --- a/lib/route/router.dart +++ b/lib/route/router.dart @@ -216,6 +216,11 @@ Route generateRoute(RouteSettings settings) { settings: settings, child: AdminMainScreen(initialIndex: initialIndex), ); + case adminStoreManagementScreenRoute: + return _buildRoute( + settings: settings, + child: const StoreManagementScreen(), + ); case orderConfirmationScreenRoute: final args = settings.arguments as Map?; @@ -243,6 +248,14 @@ Route generateRoute(RouteSettings settings) { settings: settings, child: OrderHistoryScreen(userId: userId), ); + case storeLocatorScreenRoute: + final enableSelection = (settings.arguments as Map?) + ?['enableSelection'] as bool? ?? + true; + return _buildRoute( + settings: settings, + child: StoreLocatorScreen(enableSelection: enableSelection), + ); default: return _buildRoute( diff --git a/lib/route/screen_export.dart b/lib/route/screen_export.dart index 43d59e0..b3e8744 100644 --- a/lib/route/screen_export.dart +++ b/lib/route/screen_export.dart @@ -28,7 +28,9 @@ export '/admin/view/admin_main_screen.dart'; export '/screens/order_confirmation/views/order_confirmation_screen.dart'; export '/screens/order_history/views/order_history_screen.dart'; export '/screens/product/views/product_detail_screen.dart'; +export '/screens/store_locator/views/store_locator_screen.dart'; export '/admin/category/view/category_management.dart'; +export '/admin/stores/view/store_management_screen.dart'; export '/screens/main_screen.dart'; diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 7792ab2..6442f87 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -8,6 +8,7 @@ import 'package:shop/screens/category/views/categories_list_screen.dart'; import 'package:shop/screens/checkout/views/cart_screen.dart'; import 'package:shop/screens/home/views/home_screen.dart'; import 'package:shop/screens/profile/views/profile_screen.dart'; +import 'package:shop/screens/store_locator/views/store_locator_screen.dart'; class MainScreen extends StatefulWidget { const MainScreen({super.key}); @@ -22,6 +23,10 @@ class _MainScreenState extends State { final List _screens = [ const HomeScreen(), const CategoriesListScreen(), + const StoreLocatorScreen( + enableSelection: false, + title: 'Cửa hàng CellphoneZ', + ), const CartScreen(), const ProfileScreen(), ]; @@ -109,16 +114,21 @@ class _MainScreenState extends State { label: 'Danh mục', isActive: _selectedIndex == 1, ), + _buildNavItem( + icon: Icons.storefront, + label: 'Cửa hàng', + isActive: _selectedIndex == 2, + ), _buildNavItem( icon: Icons.shopping_cart, label: 'Giỏ hàng', - isActive: _selectedIndex == 2, + isActive: _selectedIndex == 3, badgeCount: cartProvider.totalItems, ), _buildNavItem( icon: Icons.person, label: 'Tài khoản', - isActive: _selectedIndex == 3, + isActive: _selectedIndex == 4, ), ]; diff --git a/lib/screens/payment/payment_screen.dart b/lib/screens/payment/payment_screen.dart index 5f25392..57ba6f9 100644 --- a/lib/screens/payment/payment_screen.dart +++ b/lib/screens/payment/payment_screen.dart @@ -6,6 +6,9 @@ import '../../main.dart'; import '../../route/route_constants.dart'; import '../../services/order_calculation_service.dart'; import '../../models/cart_item.dart'; +import '../../models/store.dart'; +import '../../services/external_navigation_service.dart'; +import '../store_locator/views/store_locator_screen.dart'; import 'address_picker_screen.dart'; import 'package:uuid/uuid.dart'; @@ -36,6 +39,9 @@ class _PaymentScreenState extends State { late String _deliveryAddress; late OrderCalculationResult _orderSummary; bool _isProcessing = false; + Store? _selectedStore; + final ExternalNavigationService _navigationService = + ExternalNavigationService(); @override void initState() { @@ -94,6 +100,39 @@ class _PaymentScreenState extends State { } } + Future _openStoreLocator() async { + final store = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const StoreLocatorScreen( + enableSelection: true, + title: 'Chọn cửa hàng nhận hàng', + ), + ), + ); + + if (store != null) { + setState(() { + _selectedStore = store; + }); + } + } + + Future _openDirectionsToStore() async { + final store = _selectedStore; + if (store == null) return; + + try { + await _navigationService.openDirections( + destinationLat: store.latitude, + destinationLng: store.longitude, + label: store.name, + ); + } catch (error) { + _showSnackBar('Không thể mở ứng dụng bản đồ: $error'); + } + } + Future _startVNPayPayment(String orderId, double amount) async { debugPrint( '🚀 Bắt đầu _startVNPayPayment với orderId: $orderId, amount: $amount'); @@ -188,6 +227,11 @@ class _PaymentScreenState extends State { setState(() => _isProcessing = true); debugPrint('🔒 Đã set _isProcessing = true'); + if (_selectedMethod == 'store_pickup' && !_validateStoreSelection()) { + setState(() => _isProcessing = false); + return; + } + try { debugPrint('📝 Phương thức thanh toán: $_selectedMethod'); // 🧾 1️⃣ Luôn tạo đơn hàng trong Supabase trước @@ -322,7 +366,19 @@ class _PaymentScreenState extends State { } } + bool _validateStoreSelection() { + if (_selectedStore == null) { + _showSnackBar('Vui lòng chọn cửa hàng nhận hàng trước khi tiếp tục.'); + return false; + } + return true; + } + Future _handleStorePickup(String orderId) async { + if (!_validateStoreSelection()) { + return; + } + final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -332,6 +388,15 @@ class _PaymentScreenState extends State { children: [ const Text('Bạn sẽ thanh toán và nhận hàng tại cửa hàng.'), const SizedBox(height: 8), + if (_selectedStore != null) ...[ + Text( + _selectedStore!.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text(_selectedStore!.addressFull), + const SizedBox(height: 8), + ], Text( 'Tổng: ${OrderCalculationService().formatPrice(_orderSummary.total)}'), if (widget.customerNote != null && @@ -388,13 +453,18 @@ class _PaymentScreenState extends State { final paymentId = const Uuid().v4(); // 🧾 1️⃣ Tạo đơn hàng + final bool isStorePickup = method == 'store_pickup'; + await supabase.from('orders').insert({ 'id': orderId, 'user_id': user.id, 'status': status == 'success' ? 'paid' : 'pending', 'total_price': _orderSummary.total, - 'shipping_address': _deliveryAddress, + 'shipping_address': + isStorePickup ? _selectedStore?.addressFull : _deliveryAddress, 'payment_method': method, + 'pickup_type': isStorePickup ? 'store_pickup' : 'delivery', + 'pickup_store_id': isStorePickup ? _selectedStore?.id : null, }); // 🛍️ 2️⃣ Tạo các sản phẩm trong order_items @@ -460,6 +530,85 @@ class _PaymentScreenState extends State { ); } + void _showSnackBar(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + } + + Widget _buildStorePickupCard() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Cửa hàng nhận hàng', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + if (_selectedStore == null) ...[ + const Text( + 'Bạn chưa chọn cửa hàng nhận. Hãy chọn cửa hàng gần bạn nhất để nhận hàng.', + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _openStoreLocator, + icon: const Icon(Icons.store_mall_directory), + label: const Text('Chọn cửa hàng'), + ), + ] else ...[ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.storefront), + title: Text( + _selectedStore!.name, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text(_selectedStore!.addressFull), + trailing: IconButton( + icon: const Icon(Icons.edit_location_alt), + onPressed: _openStoreLocator, + ), + ), + if (_selectedStore!.phone != null) ...[ + const SizedBox(height: 4), + Text('Điện thoại: ${_selectedStore!.phone}'), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _openDirectionsToStore, + icon: const Icon(Icons.directions), + label: const Text('Chỉ đường'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: _openStoreLocator, + child: const Text('Chọn cửa hàng khác'), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -553,6 +702,11 @@ class _PaymentScreenState extends State { const SizedBox(height: 16), + if (_selectedMethod == 'store_pickup') ...[ + _buildStorePickupCard(), + const SizedBox(height: 16), + ], + // Delivery Address & Order Summary Card Card( elevation: 2, diff --git a/lib/screens/store_locator/cubit/store_locator_cubit.dart b/lib/screens/store_locator/cubit/store_locator_cubit.dart new file mode 100644 index 0000000..4915f6e --- /dev/null +++ b/lib/screens/store_locator/cubit/store_locator_cubit.dart @@ -0,0 +1,121 @@ +import 'package:bloc/bloc.dart'; +import 'package:latlong2/latlong.dart' as latlng; + +import '../../../models/store.dart'; +import '../../../services/location_service.dart'; +import '../../../services/store_service.dart'; +import 'store_locator_state.dart'; + +class StoreLocatorCubit extends Cubit { + StoreLocatorCubit({ + StoreService? storeService, + LocationService? locationService, + }) : _storeService = storeService ?? StoreService(), + _locationService = locationService ?? LocationService(), + super(const StoreLocatorState()); + + final StoreService _storeService; + final LocationService _locationService; + + Future initialize({bool loadLocation = true}) async { + emit(state.copyWith(isLoading: true, clearError: true)); + + try { + final stores = await _storeService.fetchStores(); + latlng.LatLng? position; + + if (loadLocation) { + try { + position = await _locationService.getCurrentLocationLatLng2(); + } catch (_) { + position = null; + } + } + + final storesWithDistance = + _storeService.attachDistance(stores: stores, origin: position); + final filtered = _storeService.filterStores( + stores: storesWithDistance, + origin: position, + requiredServices: state.selectedServices, + query: state.searchQuery, + ); + + emit( + state.copyWith( + isLoading: false, + stores: storesWithDistance, + filteredStores: filtered, + userLocation: position ?? state.userLocation, + availableServices: + _storeService.availableServices(storesWithDistance).toList(), + ), + ); + } catch (error) { + emit( + state.copyWith( + isLoading: false, + errorMessage: error.toString(), + ), + ); + } + } + + void refresh() { + _storeService.clearCache(); + initialize(); + } + + void updateSearch(String query) { + emit(state.copyWith(searchQuery: query)); + _applyFilters(); + } + + void toggleService(String service) { + final updated = Set.from(state.selectedServices); + if (updated.contains(service)) { + updated.remove(service); + } else { + updated.add(service); + } + + emit(state.copyWith(selectedServices: updated)); + _applyFilters(); + } + + void highlightStore(Store store) { + emit(state.copyWith(highlightedStore: store)); + } + + void selectStore(Store store) { + emit( + state.copyWith( + selectedStore: store, + highlightedStore: store, + ), + ); + } + + void clearSelection() { + emit(state.copyWith(clearSelection: true, clearHighlight: true)); + } + + void setUserLocation(latlng.LatLng position) { + emit(state.copyWith(userLocation: position)); + final storesWithDistance = + _storeService.attachDistance(stores: state.stores, origin: position); + emit(state.copyWith(stores: storesWithDistance)); + _applyFilters(); + } + + void _applyFilters() { + final filtered = _storeService.filterStores( + stores: state.stores, + query: state.searchQuery, + origin: state.userLocation, + requiredServices: state.selectedServices, + ); + + emit(state.copyWith(filteredStores: filtered)); + } +} diff --git a/lib/screens/store_locator/cubit/store_locator_state.dart b/lib/screens/store_locator/cubit/store_locator_state.dart new file mode 100644 index 0000000..c423ea8 --- /dev/null +++ b/lib/screens/store_locator/cubit/store_locator_state.dart @@ -0,0 +1,75 @@ +import 'package:equatable/equatable.dart'; +import 'package:latlong2/latlong.dart' as latlng; + +import '../../../models/store.dart'; + +class StoreLocatorState extends Equatable { + const StoreLocatorState({ + this.isLoading = false, + this.errorMessage, + this.stores = const [], + this.filteredStores = const [], + this.availableServices = const [], + this.highlightedStore, + this.selectedStore, + this.userLocation, + this.selectedServices = const {}, + this.searchQuery = '', + }); + + final bool isLoading; + final String? errorMessage; + final List stores; + final List filteredStores; + final List availableServices; + final Store? highlightedStore; + final Store? selectedStore; + final latlng.LatLng? userLocation; + final Set selectedServices; + final String searchQuery; + + bool get hasError => errorMessage != null; + + StoreLocatorState copyWith({ + bool? isLoading, + String? errorMessage, + bool clearError = false, + List? stores, + List? filteredStores, + List? availableServices, + Store? highlightedStore, + bool clearHighlight = false, + Store? selectedStore, + bool clearSelection = false, + latlng.LatLng? userLocation, + Set? selectedServices, + String? searchQuery, + }) { + return StoreLocatorState( + isLoading: isLoading ?? this.isLoading, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + stores: stores ?? this.stores, + filteredStores: filteredStores ?? this.filteredStores, + availableServices: availableServices ?? this.availableServices, + highlightedStore: clearHighlight ? null : (highlightedStore ?? this.highlightedStore), + selectedStore: clearSelection ? null : (selectedStore ?? this.selectedStore), + userLocation: userLocation ?? this.userLocation, + selectedServices: selectedServices ?? this.selectedServices, + searchQuery: searchQuery ?? this.searchQuery, + ); + } + + @override + List get props => [ + isLoading, + errorMessage, + stores, + filteredStores, + availableServices, + highlightedStore, + selectedStore, + userLocation, + selectedServices, + searchQuery, + ]; +} diff --git a/lib/screens/store_locator/views/store_locator_screen.dart b/lib/screens/store_locator/views/store_locator_screen.dart new file mode 100644 index 0000000..e210655 --- /dev/null +++ b/lib/screens/store_locator/views/store_locator_screen.dart @@ -0,0 +1,628 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' as latlng; + +import '../../../components/map_attribution_badge.dart'; +import '../../../constants.dart'; +import '../../../models/store.dart'; +import '../../../services/external_navigation_service.dart'; +import '../../../services/map_style.dart'; +import '../cubit/store_locator_cubit.dart'; +import '../cubit/store_locator_state.dart'; + +class StoreLocatorScreen extends StatefulWidget { + const StoreLocatorScreen({ + super.key, + this.enableSelection = true, + this.initialSelection, + this.showAppBar = true, + this.title, + }); + + final bool enableSelection; + final Store? initialSelection; + final bool showAppBar; + final String? title; + + static Future push(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const StoreLocatorScreen(), + ), + ); + } + + @override + State createState() => _StoreLocatorScreenState(); +} + +class _StoreLocatorScreenState extends State { + final TextEditingController _searchController = TextEditingController(); + final MapController _mapController = MapController(); + final ExternalNavigationService _navigationService = + ExternalNavigationService(); + + Timer? _searchDebounce; + + @override + void initState() { + super.initState(); + if (widget.initialSelection != null) { + _searchController.text = widget.initialSelection!.name; + } + } + + @override + void dispose() { + _searchDebounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged(String value, StoreLocatorCubit cubit) { + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 300), () { + cubit.updateSearch(value); + }); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => StoreLocatorCubit()..initialize(), + child: Builder( + builder: (context) { + final cubit = context.read(); + return Scaffold( + extendBodyBehindAppBar: true, + appBar: widget.showAppBar + ? AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text(widget.title ?? 'Tìm cửa hàng'), + ) + : null, + body: BlocConsumer( + listener: (context, state) { + final target = state.highlightedStore; + if (target != null) { + final targetPoint = + latlng.LatLng(target.latitude, target.longitude); + _mapController.move(targetPoint, 14); + } + }, + builder: (context, state) { + if (state.isLoading && state.stores.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return Stack( + children: [ + Positioned.fill(child: _buildMap(state, cubit)), + _buildTopControls(context, state, cubit), + _buildBottomSheet(context, state, cubit), + if (state.isLoading) + Positioned.fill( + child: Container( + color: Colors.black12, + child: const Center( + child: CircularProgressIndicator(), + ), + ), + ), + ], + ); + }, + ), + ); + }, + ), + ); + } + + Widget _buildTopControls( + BuildContext context, + StoreLocatorState state, + StoreLocatorCubit cubit, + ) { + final double topPadding = widget.showAppBar ? 0.0 : 12.0; + return Positioned( + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB(16, topPadding, 16, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _SearchCard( + controller: _searchController, + state: state, + onChanged: (value) => _onSearchChanged(value, cubit), + onClear: () { + _searchController.clear(); + cubit.updateSearch(''); + }, + onLocate: () => cubit.initialize(loadLocation: true), + onRefresh: cubit.refresh, + ), + const SizedBox(height: 12), + if (state.availableServices.isNotEmpty) + _FilterCard( + state: state, + onToggleService: cubit.toggleService, + ), + ], + ), + ), + ), + ); + } + + Widget _buildBottomSheet( + BuildContext context, + StoreLocatorState state, + StoreLocatorCubit cubit, + ) { + return DraggableScrollableSheet( + minChildSize: 0.2, + initialChildSize: 0.25, + maxChildSize: 0.85, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 20, + offset: const Offset(0, -4), + ), + ], + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + Expanded( + child: _buildStoreList( + context, + state, + cubit, + scrollController, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildMap(StoreLocatorState state, StoreLocatorCubit cubit) { + if (!MapStyleConfig.hasValidKey) { + return Container( + color: Colors.grey.shade300, + child: const Center( + child: Text( + 'Chưa cấu hình MAPTILER_API_KEY.\nVui lòng cập nhật .env để hiển thị bản đồ.', + textAlign: TextAlign.center, + ), + ), + ); + } + + final markers = [ + for (final store in state.filteredStores) + Marker( + point: latlng.LatLng(store.latitude, store.longitude), + width: 40, + height: 40, + child: GestureDetector( + onTap: () => cubit.highlightStore(store), + child: AnimatedScale( + scale: state.highlightedStore?.id == store.id ? 1.2 : 1.0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.location_on, + color: state.highlightedStore?.id == store.id + ? cellphoneZRed + : Colors.blueAccent, + size: 32, + ), + ), + ), + ), + ]; + + if (state.userLocation != null) { + markers.add( + Marker( + point: state.userLocation!, + width: 32, + height: 32, + child: const Icon( + Icons.my_location, + color: Colors.green, + size: 28, + ), + ), + ); + } + + final center = state.userLocation ?? + (state.filteredStores.isNotEmpty + ? latlng.LatLng( + state.filteredStores.first.latitude, + state.filteredStores.first.longitude, + ) + : latlng.LatLng(10.762622, 106.660172)); + + return Stack( + children: [ + FlutterMap( + mapController: _mapController, + options: MapOptions( + center: center, + zoom: 12, + interactiveFlags: InteractiveFlag.all, + onTap: (_, point) => cubit.setUserLocation(point), + ), + children: [ + TileLayer( + urlTemplate: MapStyleConfig.rasterTileUrl(), + userAgentPackageName: 'com.cellphonez.app', + ), + MarkerLayer(markers: markers), + ], + ), + const Positioned( + bottom: 16, + right: 16, + child: MapAttributionBadge(), + ), + ], + ); + } + + Widget _buildStoreList( + BuildContext context, + StoreLocatorState state, + StoreLocatorCubit cubit, + ScrollController scrollController, + ) { + if (state.filteredStores.isEmpty) { + return const Center( + child: Text('Không tìm thấy cửa hàng phù hợp.'), + ); + } + + return ListView.separated( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + itemCount: state.filteredStores.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final store = state.filteredStores[index]; + final isHighlighted = state.highlightedStore?.id == store.id; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isHighlighted ? cellphoneZRed : Colors.grey.shade200, + width: 1.2, + ), + boxShadow: [ + if (isHighlighted) + BoxShadow( + color: cellphoneZRed.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + color: Colors.white, + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => cubit.highlightStore(store), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + store.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + if (store.distanceKm != null) + Text( + '${store.distanceKm!.toStringAsFixed(1)} km', + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + store.addressFull, + style: const TextStyle(color: Colors.black54), + ), + if (store.phone != null) ...[ + const SizedBox(height: 4), + Text( + store.phone!, + style: const TextStyle(color: Colors.black87), + ), + ], + const SizedBox(height: 8), + if (store.services.isNotEmpty) + Wrap( + spacing: 6, + runSpacing: 4, + children: store.services + .map( + (service) => Chip( + label: Text(service), + backgroundColor: Colors.blueGrey.shade50, + ), + ) + .toList(), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _StatusBadge(isActive: store.isActive), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: () async { + try { + await _navigationService.openDirections( + destinationLat: store.latitude, + destinationLng: store.longitude, + originLat: state.userLocation?.latitude, + originLng: state.userLocation?.longitude, + label: store.name, + ); + } catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Không thể mở ứng dụng bản đồ: $error', + ), + ), + ); + } + } + }, + icon: const Icon(Icons.directions), + label: const Text('Chỉ đường'), + ), + ), + const SizedBox(width: 12), + if (widget.enableSelection) + Expanded( + child: ElevatedButton( + onPressed: () { + cubit.selectStore(store); + Navigator.of(context).pop(store); + }, + style: ElevatedButton.styleFrom( + backgroundColor: cellphoneZRed, + ), + child: const Text('Chọn làm điểm lấy'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _SearchCard extends StatelessWidget { + const _SearchCard({ + required this.controller, + required this.state, + required this.onChanged, + required this.onClear, + required this.onLocate, + required this.onRefresh, + }); + + final TextEditingController controller; + final StoreLocatorState state; + final ValueChanged onChanged; + final VoidCallback onClear; + final VoidCallback onLocate; + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + return Material( + elevation: 6, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + hintText: 'Tìm theo tên, thành phố, địa chỉ...', + filled: true, + fillColor: Colors.white, + prefixIcon: const Icon(Icons.search), + suffixIcon: state.searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: onClear, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + ), + ), + ), + const SizedBox(width: 8), + _FloatingRoundButton( + icon: Icons.my_location, + tooltip: 'Định vị hiện tại', + onTap: onLocate, + ), + const SizedBox(width: 8), + _FloatingRoundButton( + icon: Icons.refresh, + tooltip: 'Làm mới', + onTap: onRefresh, + ), + ], + ), + ), + ); + } +} + +class _FilterCard extends StatelessWidget { + const _FilterCard({ + required this.state, + required this.onToggleService, + }); + + final StoreLocatorState state; + final void Function(String service) onToggleService; + + @override + Widget build(BuildContext context) { + if (state.availableServices.isEmpty) { + return const SizedBox.shrink(); + } + return Material( + elevation: 4, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final service in state.availableServices) ...[ + FilterChip( + label: Text(service), + selected: state.selectedServices.contains(service), + onSelected: (_) => onToggleService(service), + ), + const SizedBox(width: 8), + ], + ], + ), + ), + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.isActive}); + + final bool isActive; + + @override + Widget build(BuildContext context) { + final Color accent = isActive ? Colors.green : Colors.grey; + return Container( + height: 46, + decoration: BoxDecoration( + color: isActive ? Colors.green.shade50 : Colors.grey.shade200, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: accent.withOpacity(0.4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isActive ? Icons.check_circle : Icons.pause_circle_outline, + color: accent, + size: 20, + ), + const SizedBox(width: 6), + Text( + isActive ? 'Đang hoạt động' : 'Tạm đóng', + style: TextStyle( + color: accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _FloatingRoundButton extends StatelessWidget { + const _FloatingRoundButton({ + required this.icon, + required this.onTap, + this.tooltip, + }); + + final IconData icon; + final VoidCallback onTap; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final button = Material( + color: Colors.white, + shape: const CircleBorder(), + elevation: 3, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: SizedBox( + width: 44, + height: 44, + child: Icon(icon, color: cellphoneZRed), + ), + ), + ); + return tooltip != null ? Tooltip(message: tooltip!, child: button) : button; + } +} diff --git a/lib/services/database_schema.dart b/lib/services/database_schema.dart index 0709bcc..09d2163 100644 --- a/lib/services/database_schema.dart +++ b/lib/services/database_schema.dart @@ -18,6 +18,7 @@ class DatabaseSchema { static const OrderItemsTable orderItems = OrderItemsTable(); static const UserRolesTable userRoles = UserRolesTable(); static const PaymentsTable payments = PaymentsTable(); + static const StoresTable stores = StoresTable(); static const StorageBuckets storage = StorageBuckets(); } @@ -32,6 +33,7 @@ class TableNames { final String cartItems = 'cart_items'; final String orders = 'orders'; final String orderItems = 'order_items'; + final String stores = 'stores'; } /// Các cột phổ biến xuất hiện trong nhiều bảng @@ -117,6 +119,9 @@ class OrdersTable { final String shippingAddress = 'shipping_address'; final String paymentMethod = 'payment_method'; final String createdAt = 'created_at'; + final String pickupType = 'pickup_type'; + final String pickupStoreId = 'pickup_store_id'; + final String pickupWindow = 'pickup_window'; } /// Schema cho bảng Order Items @@ -164,3 +169,23 @@ class UserRolesTable { final String userId = 'user_id'; final String role = 'role'; } + +/// Schema cho bảng Stores +class StoresTable { + const StoresTable(); + + final String table = 'stores'; + + final String id = 'id'; + final String name = 'name'; + final String addressFull = 'address_full'; + final String city = 'city'; + final String latitude = 'latitude'; + final String longitude = 'longitude'; + final String phone = 'phone'; + final String services = 'services'; + final String openingHours = 'opening_hours'; + final String isActive = 'is_active'; + final String createdAt = 'created_at'; + final String updatedAt = 'updated_at'; +} diff --git a/lib/services/dependency_injection.dart b/lib/services/dependency_injection.dart index cc280bb..9a64360 100644 --- a/lib/services/dependency_injection.dart +++ b/lib/services/dependency_injection.dart @@ -8,8 +8,10 @@ import '../repository/cart_repository.dart'; import '../repository/order_repository.dart'; import '../repository/order_item_repository.dart'; import '../repository/user_repository.dart'; +import '../repository/store_repository.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'storage_service.dart'; +import 'store_service.dart'; final GetIt getIt = GetIt.instance; @@ -45,7 +47,9 @@ Future setupDependencies() async { getIt.registerLazySingleton( () => OrderItemRepository()); getIt.registerLazySingleton(() => UserRepository()); + getIt.registerLazySingleton(() => StoreRepository()); getIt.registerLazySingleton(() => StorageService()); + getIt.registerLazySingleton(() => StoreService()); print('All repositories registered successfully'); } catch (e) { print('Error during dependency setup: $e'); diff --git a/lib/services/external_navigation_service.dart b/lib/services/external_navigation_service.dart new file mode 100644 index 0000000..fe63671 --- /dev/null +++ b/lib/services/external_navigation_service.dart @@ -0,0 +1,93 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:url_launcher/url_launcher.dart'; + +enum ExternalMapProvider { googleMaps, mapTiler } + +class ExternalNavigationService { + ExternalNavigationService({ + ExternalMapProvider? provider, + }) : _provider = provider ?? _resolveDefaultProvider(); + + final ExternalMapProvider _provider; + + Future openDirections({ + required double destinationLat, + required double destinationLng, + double? originLat, + double? originLng, + String? label, + ExternalMapProvider? overrideProvider, + }) async { + final provider = overrideProvider ?? _provider; + final uri = switch (provider) { + ExternalMapProvider.mapTiler => _mapTilerUri( + destinationLat: destinationLat, + destinationLng: destinationLng, + originLat: originLat, + originLng: originLng, + ), + ExternalMapProvider.googleMaps => _googleMapsUri( + destinationLat: destinationLat, + destinationLng: destinationLng, + originLat: originLat, + originLng: originLng, + label: label, + ), + }; + + if (!await canLaunchUrl(uri)) { + throw Exception('Không thể mở ứng dụng bản đồ'); + } + + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + static ExternalMapProvider _resolveDefaultProvider() { + final raw = dotenv.env['MAP_DIRECTIONS_PROVIDER'] ?? 'google'; + switch (raw.toLowerCase()) { + case 'maptiler': + case 'map_tiler': + return ExternalMapProvider.mapTiler; + default: + return ExternalMapProvider.googleMaps; + } + } + + Uri _googleMapsUri({ + required double destinationLat, + required double destinationLng, + double? originLat, + double? originLng, + String? label, + }) { + final queryParameters = { + 'api': '1', + 'destination': + '$destinationLat,$destinationLng${label != null ? '($label)' : ''}', + 'travelmode': 'driving', + }; + + if (originLat != null && originLng != null) { + queryParameters['origin'] = '$originLat,$originLng'; + } + + return Uri.https('www.google.com', '/maps/dir/', queryParameters); + } + + Uri _mapTilerUri({ + required double destinationLat, + required double destinationLng, + double? originLat, + double? originLng, + }) { + final buffer = StringBuffer('https://www.maptiler.com/directions/?'); + if (originLat != null && originLng != null) { + buffer.write( + 'start=${originLng.toStringAsFixed(6)},${originLat.toStringAsFixed(6)}&'); + } + buffer.write( + 'end=${destinationLng.toStringAsFixed(6)},${destinationLat.toStringAsFixed(6)}'); + buffer.write('&travelMode=car'); + return Uri.parse(buffer.toString()); + } +} diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index c929a19..a3a7f20 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -1,35 +1,105 @@ +import 'package:geocoding/geocoding.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:latlong2/latlong.dart' as latlng; + import '../constants.dart'; class LocationService { - // Convert LatLng to address using reverse geocoding Future getAddressFromLatLng(LatLng position) async { - // TODO: Implement reverse geocoding using Google Maps API - // For now, return a formatted string of coordinates - return '${position.latitude}, ${position.longitude}'; + try { + final placemarks = await placemarkFromCoordinates( + position.latitude, + position.longitude, + localeIdentifier: 'vi_VN', + ); + + if (placemarks.isEmpty) { + return '${position.latitude.toStringAsFixed(5)}, ' + '${position.longitude.toStringAsFixed(5)}'; + } + + final place = placemarks.first; + final segments = [ + place.street, + place.subLocality, + place.locality, + place.administrativeArea, + ].where((segment) => segment != null && segment!.isNotEmpty).toList(); + + if (segments.isEmpty) { + return '${position.latitude.toStringAsFixed(5)}, ' + '${position.longitude.toStringAsFixed(5)}'; + } + + return segments.join(', '); + } catch (_) { + return '${position.latitude.toStringAsFixed(5)}, ' + '${position.longitude.toStringAsFixed(5)}'; + } } - // Get user's current location Future getCurrentLocation() async { - // TODO: Implement getting user's current location - // For now, return a default location (e.g., Ho Chi Minh City) - return const LatLng(10.762622, 106.660172); + final position = await _resolvePosition(); + return LatLng(position.latitude, position.longitude); + } + + Future getCurrentLocationLatLng2() async { + final position = await _resolvePosition(); + return latlng.LatLng(position.latitude, position.longitude); } - // Calculate shipping fee based on distance double calculateShippingFee(LatLng source, LatLng destination) { - // TODO: Implement actual distance calculation and fee computation - return DEFAULT_SHIPPING_FEE; + final distanceMeters = Geolocator.distanceBetween( + source.latitude, + source.longitude, + destination.latitude, + destination.longitude, + ); + + final distanceKm = distanceMeters / 1000; + const thresholdKm = 3.0; + const surchargePerKm = 5000.0; // 5,000đ mỗi km bổ sung + + if (distanceKm <= thresholdKm) { + return DEFAULT_SHIPPING_FEE; + } + + final extraDistance = distanceKm - thresholdKm; + return DEFAULT_SHIPPING_FEE + (extraDistance.ceil() * surchargePerKm); } - // Save address to favorites Future saveFavoriteAddress(String address) async { - // TODO: Implement saving address to local storage or database + // TODO: Persist to Supabase or local storage } - // Get list of favorite addresses Future> getFavoriteAddresses() async { - // TODO: Implement getting saved addresses + // TODO: Retrieve from persistence layer return []; } -} \ No newline at end of file + + Future _resolvePosition() async { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + return Future.error('Location services are disabled.'); + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.denied) { + return Future.error('Location permission denied'); + } + + if (permission == LocationPermission.deniedForever) { + return Future.error( + 'Location permissions are permanently denied, we cannot request permissions.'); + } + + return Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + } +} diff --git a/lib/services/map_style.dart b/lib/services/map_style.dart new file mode 100644 index 0000000..ae5b3ad --- /dev/null +++ b/lib/services/map_style.dart @@ -0,0 +1,20 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +class MapStyleConfig { + static String get apiKey => dotenv.env['MAPTILER_API_KEY'] ?? ''; + + static bool get hasValidKey => apiKey.isNotEmpty; + + static String rasterTileUrl({String style = 'streets-v2'}) { + final key = apiKey; + return 'https://api.maptiler.com/maps/$style/{z}/{x}/{y}.png?key=$key'; + } + + static String styleJsonUrl({String style = 'streets-v2'}) { + final key = apiKey; + return 'https://api.maptiler.com/maps/$style/style.json?key=$key'; + } + + static const String attribution = + '© MapTiler © OpenStreetMap contributors'; +} diff --git a/lib/services/services.dart b/lib/services/services.dart index e43e9fd..55062ca 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -5,3 +5,5 @@ export 'product_service.dart'; export 'cart_service.dart'; export 'order_service.dart'; export 'storage_service.dart'; +export 'store_service.dart'; +export 'external_navigation_service.dart'; diff --git a/lib/services/store_service.dart b/lib/services/store_service.dart new file mode 100644 index 0000000..239f6bf --- /dev/null +++ b/lib/services/store_service.dart @@ -0,0 +1,306 @@ +import 'dart:collection'; + +import 'package:get_it/get_it.dart'; +import 'package:latlong2/latlong.dart' as latlng; + +import '../common/app_logger.dart'; +import '../models/store.dart'; +import '../repository/store_repository.dart'; + +class StoreService { + StoreService({ + StoreRepository? storeRepository, + AppLogger? logger, + latlng.Distance? distance, + }) : _storeRepository = + storeRepository ?? GetIt.instance(), + _logger = logger ?? AppLogger.instance, + _distance = distance ?? const latlng.Distance(); + + final StoreRepository _storeRepository; + final AppLogger _logger; + final latlng.Distance _distance; + + List? _cache; + DateTime? _lastFetch; + + Future> fetchStores({bool forceRefresh = false}) async { + if (!forceRefresh && _cache != null) { + return _cache!; + } + + final stores = await _storeRepository.getStores(); + _cache = stores; + _lastFetch = DateTime.now(); + return stores; + } + + List attachDistance({ + required List stores, + latlng.LatLng? origin, + }) { + if (origin == null) { + return stores; + } + + return stores + .map( + (store) => store.copyWith( + distanceKm: _distance.as( + latlng.LengthUnit.Kilometer, + latlng.LatLng(store.latitude, store.longitude), + origin, + ), + ), + ) + .toList(); + } + + List filterStores({ + required List stores, + String query = '', + latlng.LatLng? origin, + Set? requiredServices, + }) { + Iterable results = stores; + final normalizedQuery = query.trim().toLowerCase(); + + if (normalizedQuery.isNotEmpty) { + results = results.where( + (store) => + store.name.toLowerCase().contains(normalizedQuery) || + (store.city ?? '').toLowerCase().contains(normalizedQuery) || + store.addressFull.toLowerCase().contains(normalizedQuery), + ); + } + + if (requiredServices != null && requiredServices.isNotEmpty) { + results = results.where( + (store) => + requiredServices.every((service) => store.services.contains(service)), + ); + } + + final sorted = results.toList() + ..sort( + (a, b) { + final distanceA = a.distanceKm ?? double.infinity; + final distanceB = b.distanceKm ?? double.infinity; + return distanceA.compareTo(distanceB); + }, + ); + + return sorted; + } + + bool isStoreOpenNow(Store store) { + final hours = store.openingHours; + if (hours == null || hours.isEmpty) { + return true; + } + + if (hours['open_now'] is bool) { + return hours['open_now'] as bool; + } + + final now = DateTime.now(); + final key = _weekdayKey(now.weekday); + final dynamic segment = hours[key] ?? hours[_weekdayName(now.weekday)]; + + if (segment == null) { + return true; + } + + if (segment is Map) { + final open = (segment['open'] ?? segment['from']) as String?; + final close = (segment['close'] ?? segment['to']) as String?; + + if (open == null || close == null) { + return true; + } + + final openTime = _parseTime(open, now); + final closeTime = _parseTime(close, now); + if (openTime == null || closeTime == null) { + return true; + } + + if (closeTime.isBefore(openTime)) { + // Overnight shift + return now.isAfter(openTime) || now.isBefore(closeTime); + } + + return now.isAfter(openTime) && now.isBefore(closeTime); + } + + if (segment is List) { + return segment.any((entry) { + if (entry is Map) { + final open = entry['open'] as String?; + final close = entry['close'] as String?; + if (open == null || close == null) return false; + final openTime = _parseTime(open, now); + final closeTime = _parseTime(close, now); + if (openTime == null || closeTime == null) return false; + if (closeTime.isBefore(openTime)) { + return now.isAfter(openTime) || now.isBefore(closeTime); + } + return now.isAfter(openTime) && now.isBefore(closeTime); + } + return false; + }); + } + + return true; + } + + Future createStore({ + required String name, + required String addressFull, + required double latitude, + required double longitude, + String? city, + String? phone, + List? services, + Map? openingHours, + bool isActive = true, + }) async { + final draft = StoreDraft( + name: name.trim(), + addressFull: addressFull.trim(), + city: city?.trim(), + latitude: latitude, + longitude: longitude, + phone: phone?.trim(), + services: services ?? const [], + openingHours: openingHours, + isActive: isActive, + ); + + final store = await _storeRepository.createStore(draft); + await refreshCache(); + return store; + } + + Future updateStore( + Store store, { + String? name, + String? addressFull, + String? city, + double? latitude, + double? longitude, + String? phone, + List? services, + Map? openingHours, + bool? isActive, + }) async { + final draft = StoreDraft( + name: name ?? store.name, + addressFull: addressFull ?? store.addressFull, + city: city ?? store.city, + latitude: latitude ?? store.latitude, + longitude: longitude ?? store.longitude, + phone: phone ?? store.phone, + services: services ?? store.services, + openingHours: openingHours ?? store.openingHours, + isActive: isActive ?? store.isActive, + ); + + final result = await _storeRepository.updateStore(store.id, draft); + await refreshCache(); + return result; + } + + Future setStoreActive(String id, bool isActive) async { + final result = await _storeRepository.updateStoreStatus(id, isActive); + await refreshCache(); + return result; + } + + Future deleteStore(String id) async { + await _storeRepository.deleteStore(id); + await refreshCache(); + } + + Future> refreshCache() async { + _cache = await _storeRepository.getStores(); + _lastFetch = DateTime.now(); + return _cache!; + } + + void clearCache() { + _cache = null; + _lastFetch = null; + } + + UnmodifiableListView availableServices(List stores) { + final services = {}; + for (final store in stores) { + services.addAll(store.services); + } + + final sorted = services.toList()..sort(); + return UnmodifiableListView(sorted); + } + + String _weekdayKey(int weekday) { + switch (weekday) { + case DateTime.monday: + return 'mon'; + case DateTime.tuesday: + return 'tue'; + case DateTime.wednesday: + return 'wed'; + case DateTime.thursday: + return 'thu'; + case DateTime.friday: + return 'fri'; + case DateTime.saturday: + return 'sat'; + case DateTime.sunday: + return 'sun'; + default: + return 'mon'; + } + } + + String _weekdayName(int weekday) { + switch (weekday) { + case DateTime.monday: + return 'monday'; + case DateTime.tuesday: + return 'tuesday'; + case DateTime.wednesday: + return 'wednesday'; + case DateTime.thursday: + return 'thursday'; + case DateTime.friday: + return 'friday'; + case DateTime.saturday: + return 'saturday'; + case DateTime.sunday: + return 'sunday'; + default: + return 'monday'; + } + } + + DateTime? _parseTime(String value, DateTime reference) { + final parts = value.split(':'); + if (parts.length < 2) { + return null; + } + final hour = int.tryParse(parts[0]); + final minute = int.tryParse(parts[1]); + if (hour == null || minute == null) { + return null; + } + return DateTime( + reference.year, + reference.month, + reference.day, + hour, + minute, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 87ed481..dc08e6a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -462,6 +462,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb" + url: "https://pub.dev" + source: hosted + version: "6.2.1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -872,6 +880,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -904,6 +920,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" logger: dependency: "direct main" description: @@ -952,6 +976,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -1080,6 +1112,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" pool: dependency: transitive description: @@ -1112,6 +1152,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.5" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" prompts: dependency: transitive description: @@ -1405,6 +1453,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" url_launcher: dependency: "direct main" description: @@ -1597,6 +1653,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.5.1" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9d13fc4..543e497 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: image: ^4.2.0 path: ^1.9.0 uuid: ^4.5.1 + flutter_map: ^6.1.0 + latlong2: ^0.9.0 intl: ^0.19.0 shared_preferences: ^2.2.2 diff --git a/supabase_schema.sql b/supabase_schema.sql index 0fdc020..8fb19ab 100644 --- a/supabase_schema.sql +++ b/supabase_schema.sql @@ -1,154 +1,92 @@ - -- Supabase Database Schema for E-commerce App - -- Run these commands in Supabase SQL Editor - - -- Enable UUID extension - CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - - -- Enable Row Level Security - ALTER DATABASE postgres SET "app.jwt_secret" TO 'your-jwt-secret'; - - - -- Categories Table - CREATE TABLE categories ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name TEXT NOT NULL, - parent_id UUID REFERENCES categories(id) ON DELETE CASCADE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() - ); - - -- Products Table - CREATE TABLE products ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name TEXT NOT NULL, - price DECIMAL(10,2) NOT NULL, - description TEXT, - image_url TEXT, - category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE, - is_available BOOLEAN DEFAULT true, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() - ); - - -- Cart Items Table - CREATE TABLE cart_items ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES user_profiles(id) ON DELETE CASCADE, - product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, - quantity INTEGER NOT NULL CHECK (quantity > 0), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - UNIQUE(user_id, product_id) - ); - - -- Orders Table - CREATE TABLE orders ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES user_profiles(id) ON DELETE CASCADE, - total_price DECIMAL(10,2) NOT NULL, - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'shipping', 'completed', 'cancelled')), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() - ); - - -- Order Items Table - CREATE TABLE order_items ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, - product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, - quantity INTEGER NOT NULL CHECK (quantity > 0), - price DECIMAL(10,2) NOT NULL, -- Store price at time of order - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() - ); - - -- Indexes for better performance - CREATE INDEX idx_products_category ON products(category_id); - CREATE INDEX idx_products_available ON products(is_available); - CREATE INDEX idx_cart_items_user ON cart_items(user_id); - CREATE INDEX idx_orders_user ON orders(user_id); - CREATE INDEX idx_orders_status ON orders(status); - CREATE INDEX idx_order_items_order ON order_items(order_id); - - -- Row Level Security Policies - - -- User Profiles - ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY; - CREATE POLICY "Users can view own profile" ON user_profiles FOR SELECT USING (auth.uid() = id); - CREATE POLICY "Users can update own profile" ON user_profiles FOR UPDATE USING (auth.uid() = id); - CREATE POLICY "Users can insert own profile" ON user_profiles FOR INSERT WITH CHECK (auth.uid() = id); - - -- Categories (Public read access) - ALTER TABLE categories ENABLE ROW LEVEL SECURITY; - CREATE POLICY "Anyone can view categories" ON categories FOR SELECT TO public USING (true); - - -- Products (Public read access) - ALTER TABLE products ENABLE ROW LEVEL SECURITY; - CREATE POLICY "Anyone can view available products" ON products FOR SELECT TO public USING (is_available = true); - - -- Cart Items - ALTER TABLE cart_items ENABLE ROW LEVEL SECURITY; - CREATE POLICY "Users can view own cart items" ON cart_items FOR SELECT USING (auth.uid() = user_id); - CREATE POLICY "Users can insert own cart items" ON cart_items FOR INSERT WITH CHECK (auth.uid() = user_id); - CREATE POLICY "Users can update own cart items" ON cart_items FOR UPDATE USING (auth.uid() = user_id); - CREATE POLICY "Users can delete own cart items" ON cart_items FOR DELETE USING (auth.uid() = user_id); - - -- Orders - ALTER TABLE orders ENABLE ROW LEVEL SECURITY; - CREATE POLICY "Users can view own orders" ON orders FOR SELECT USING (auth.uid() = user_id); - CREATE POLICY "Users can insert own orders" ON orders FOR INSERT WITH CHECK (auth.uid() = user_id); - - -- Order Items - ALTER TABLE order_items ENABLE ROW LEVEL SECURITY; - CREATE POLICY "Users can view own order items" ON order_items FOR SELECT USING ( - auth.uid() IN (SELECT user_id FROM orders WHERE orders.id = order_items.order_id) - ); - CREATE POLICY "Users can insert own order items" ON order_items FOR INSERT WITH CHECK ( - auth.uid() IN (SELECT user_id FROM orders WHERE orders.id = order_items.order_id) - ); - - -- Functions for updated_at timestamp - CREATE OR REPLACE FUNCTION update_updated_at_column() - RETURNS TRIGGER AS $$ - BEGIN - NEW.updated_at = NOW(); - RETURN NEW; - END; - $$ language 'plpgsql'; - - -- Triggers for updated_at - CREATE TRIGGER update_user_profiles_updated_at BEFORE UPDATE ON user_profiles - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - - CREATE TRIGGER update_categories_updated_at BEFORE UPDATE ON categories - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - - CREATE TRIGGER update_products_updated_at BEFORE UPDATE ON products - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - - CREATE TRIGGER update_cart_items_updated_at BEFORE UPDATE ON cart_items - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - - CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - - -- Sample data (optional) - INSERT INTO categories (name) VALUES - ('Electronics'), - ('Clothing'), - ('Books'), - ('Home & Garden'); - - INSERT INTO categories (name, parent_id) VALUES - ('Smartphones', (SELECT id FROM categories WHERE name = 'Electronics')), - ('Laptops', (SELECT id FROM categories WHERE name = 'Electronics')), - ('Men''s Clothing', (SELECT id FROM categories WHERE name = 'Clothing')), - ('Women''s Clothing', (SELECT id FROM categories WHERE name = 'Clothing')); - - -- You can add sample products here as well - INSERT INTO products (name, price, description, category_id, image_url) VALUES - ('iPhone 15', 999.99, 'Latest iPhone with advanced features', - (SELECT id FROM categories WHERE name = 'Smartphones'), - 'https://example.com/iphone15.jpg'), - ('MacBook Pro', 1999.99, 'Powerful laptop for professionals', - (SELECT id FROM categories WHERE name = 'Laptops'), - 'https://example.com/macbook.jpg'); \ No newline at end of file +-- WARNING: This schema is for context only and is not meant to be run. +-- Table order and constraints may not be valid for execution. + +CREATE TABLE public.cart_items ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + user_id uuid NOT NULL DEFAULT auth.uid(), + product_id uuid NOT NULL, + quantity smallint NOT NULL DEFAULT '0'::smallint, + CONSTRAINT cart_items_pkey PRIMARY KEY (id), + CONSTRAINT cart_items_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id), + CONSTRAINT cart_items_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) +); +CREATE TABLE public.categories ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + name character varying NOT NULL, + parent_id uuid, + is_popular boolean DEFAULT false, + CONSTRAINT categories_pkey PRIMARY KEY (id), + CONSTRAINT categories_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.categories(id) +); +CREATE TABLE public.order_items ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + order_id uuid NOT NULL, + product_id uuid NOT NULL, + quantity double precision, + price double precision, + CONSTRAINT order_items_pkey PRIMARY KEY (id), + CONSTRAINT order_items_order_id_fkey FOREIGN KEY (order_id) REFERENCES public.orders(id), + CONSTRAINT order_items_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id) +); +CREATE TABLE public.orders ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + status character varying, + total_price double precision DEFAULT '0'::double precision, + shipping_address character varying, + payment_method character varying, + user_id uuid, + pickup_type text NOT NULL DEFAULT 'delivery'::text CHECK (pickup_type = ANY (ARRAY['delivery'::text, 'store_pickup'::text])), + pickup_store_id uuid, + pickup_window tstzrange, + CONSTRAINT orders_pkey PRIMARY KEY (id), + CONSTRAINT orders_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id), + CONSTRAINT orders_pickup_store_id_fkey FOREIGN KEY (pickup_store_id) REFERENCES public.stores(id) +); +CREATE TABLE public.payments ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + order_id uuid, + user_id uuid, + amount numeric NOT NULL, + status text DEFAULT 'pending'::text CHECK (status = ANY (ARRAY['pending'::text, 'success'::text, 'failed'::text])), + method text, + transaction_id text, + created_at timestamp with time zone DEFAULT now(), + CONSTRAINT payments_pkey PRIMARY KEY (id), + CONSTRAINT payments_order_id_fkey FOREIGN KEY (order_id) REFERENCES public.orders(id), + CONSTRAINT payments_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) +); +CREATE TABLE public.products ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + name character varying NOT NULL DEFAULT ''::character varying, + price double precision DEFAULT '0'::double precision, + description character varying DEFAULT ''::character varying, + image_url character varying DEFAULT ''::character varying, + is_available boolean NOT NULL, + category_id uuid, + slug text UNIQUE, + CONSTRAINT products_pkey PRIMARY KEY (id), + CONSTRAINT products_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.categories(id) +); +CREATE TABLE public.stores ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + name text NOT NULL, + address_full text NOT NULL, + city text, + latitude double precision NOT NULL, + longitude double precision NOT NULL, + phone text, + services jsonb DEFAULT '[]'::jsonb, + opening_hours jsonb DEFAULT '{}'::jsonb, + is_active boolean NOT NULL DEFAULT true, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT stores_pkey PRIMARY KEY (id) +); +CREATE TABLE public.user_roles ( + user_id uuid NOT NULL, + role text NOT NULL DEFAULT '''user'''::text, + CONSTRAINT user_roles_pkey PRIMARY KEY (user_id), + CONSTRAINT user_roles_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) +);