From ce7396f729525ad6724108d9c6e933f7a9a90b92 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Fri, 7 Nov 2025 13:56:51 +0700 Subject: [PATCH] add start/business unit --- lib/core/constants/storage_constants.dart | 1 + lib/core/router/app_router.dart | 29 +- .../auth/data/models/business_unit_model.dart | 91 ++++ .../data/models/business_unit_model.g.dart | 53 +++ .../auth/domain/entities/business_unit.dart | 93 ++++ .../pages/business_unit_selection_page.dart | 396 ++++++++++++++++++ .../auth/presentation/pages/login_page.dart | 13 +- .../presentation/pages/register_page.dart | 20 +- lib/hive_registrar.g.dart | 3 + 9 files changed, 687 insertions(+), 12 deletions(-) create mode 100644 lib/features/auth/data/models/business_unit_model.dart create mode 100644 lib/features/auth/data/models/business_unit_model.g.dart create mode 100644 lib/features/auth/domain/entities/business_unit.dart create mode 100644 lib/features/auth/presentation/pages/business_unit_selection_page.dart diff --git a/lib/core/constants/storage_constants.dart b/lib/core/constants/storage_constants.dart index 2552c3a..57d155d 100644 --- a/lib/core/constants/storage_constants.dart +++ b/lib/core/constants/storage_constants.dart @@ -124,6 +124,7 @@ class HiveTypeIds { static const int promotionModel = 26; static const int categoryModel = 27; static const int favoriteModel = 28; + static const int businessUnitModel = 29; // Enums (30-59) static const int userRole = 30; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index c2195d8..3f6b649 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -10,6 +10,8 @@ import 'package:go_router/go_router.dart'; import 'package:worker/features/account/presentation/pages/addresses_page.dart'; import 'package:worker/features/account/presentation/pages/change_password_page.dart'; import 'package:worker/features/account/presentation/pages/profile_edit_page.dart'; +import 'package:worker/features/auth/domain/entities/business_unit.dart'; +import 'package:worker/features/auth/presentation/pages/business_unit_selection_page.dart'; import 'package:worker/features/auth/presentation/pages/login_page.dart'; import 'package:worker/features/auth/presentation/pages/register_page.dart'; import 'package:worker/features/cart/presentation/pages/cart_page.dart'; @@ -62,8 +64,30 @@ class AppRouter { GoRoute( path: RouteNames.register, name: RouteNames.register, - pageBuilder: (context, state) => - MaterialPage(key: state.pageKey, child: const RegisterPage()), + pageBuilder: (context, state) { + final extra = state.extra as Map?; + return MaterialPage( + key: state.pageKey, + child: RegisterPage( + selectedBusinessUnit: extra?['businessUnit'] as BusinessUnit?, + ), + ); + }, + ), + GoRoute( + path: RouteNames.businessUnitSelection, + name: RouteNames.businessUnitSelection, + pageBuilder: (context, state) { + final extra = state.extra as Map?; + return MaterialPage( + key: state.pageKey, + child: BusinessUnitSelectionPage( + businessUnits: extra?['businessUnits'] as List?, + isRegistrationFlow: + (extra?['isRegistrationFlow'] as bool?) ?? false, + ), + ); + }, ), // Main Route (with bottom navigation) @@ -447,6 +471,7 @@ class RouteNames { static const String login = '/login'; static const String otpVerification = '/otp-verification'; static const String register = '/register'; + static const String businessUnitSelection = '/business-unit-selection'; } /// Route Extensions diff --git a/lib/features/auth/data/models/business_unit_model.dart b/lib/features/auth/data/models/business_unit_model.dart new file mode 100644 index 0000000..dd0849e --- /dev/null +++ b/lib/features/auth/data/models/business_unit_model.dart @@ -0,0 +1,91 @@ +/// Business Unit Data Model +/// +/// Hive model for local storage of business units. +library; + +import 'package:hive_ce/hive.dart'; +import 'package:worker/core/constants/storage_constants.dart'; +import 'package:worker/features/auth/domain/entities/business_unit.dart'; + +part 'business_unit_model.g.dart'; + +/// Business Unit Model for Hive storage +@HiveType(typeId: HiveTypeIds.businessUnitModel) +class BusinessUnitModel extends HiveObject { + /// Unique business unit identifier + @HiveField(0) + String id; + + /// Business unit code (e.g., "VIKD", "HSKD", "LPKD") + @HiveField(1) + String code; + + /// Display name + @HiveField(2) + String name; + + /// Description + @HiveField(3) + String? description; + + /// Whether this is the default unit + @HiveField(4) + bool isDefault; + + BusinessUnitModel({ + required this.id, + required this.code, + required this.name, + this.description, + this.isDefault = false, + }); + + /// Convert to domain entity + BusinessUnit toEntity() { + return BusinessUnit( + id: id, + code: code, + name: name, + description: description, + isDefault: isDefault, + ); + } + + /// Create from domain entity + factory BusinessUnitModel.fromEntity(BusinessUnit entity) { + return BusinessUnitModel( + id: entity.id, + code: entity.code, + name: entity.name, + description: entity.description, + isDefault: entity.isDefault, + ); + } + + /// Create from JSON + factory BusinessUnitModel.fromJson(Map json) { + return BusinessUnitModel( + id: json['id'] as String, + code: json['code'] as String, + name: json['name'] as String, + description: json['description'] as String?, + isDefault: json['is_default'] as bool? ?? false, + ); + } + + /// Convert to JSON + Map toJson() { + return { + 'id': id, + 'code': code, + 'name': name, + 'description': description, + 'is_default': isDefault, + }; + } + + @override + String toString() { + return 'BusinessUnitModel(id: $id, code: $code, name: $name)'; + } +} diff --git a/lib/features/auth/data/models/business_unit_model.g.dart b/lib/features/auth/data/models/business_unit_model.g.dart new file mode 100644 index 0000000..0d3a8d2 --- /dev/null +++ b/lib/features/auth/data/models/business_unit_model.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'business_unit_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class BusinessUnitModelAdapter extends TypeAdapter { + @override + final typeId = 29; + + @override + BusinessUnitModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return BusinessUnitModel( + id: fields[0] as String, + code: fields[1] as String, + name: fields[2] as String, + description: fields[3] as String?, + isDefault: fields[4] == null ? false : fields[4] as bool, + ); + } + + @override + void write(BinaryWriter writer, BusinessUnitModel obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.code) + ..writeByte(2) + ..write(obj.name) + ..writeByte(3) + ..write(obj.description) + ..writeByte(4) + ..write(obj.isDefault); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BusinessUnitModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/features/auth/domain/entities/business_unit.dart b/lib/features/auth/domain/entities/business_unit.dart new file mode 100644 index 0000000..8077442 --- /dev/null +++ b/lib/features/auth/domain/entities/business_unit.dart @@ -0,0 +1,93 @@ +/// Domain Entity: Business Unit +/// +/// Represents a business unit that a user can access. +library; + +/// Business Unit Entity +/// +/// Represents a business division or unit that a user has access to. +class BusinessUnit { + /// Unique business unit identifier + final String id; + + /// Business unit code (e.g., "VIKD", "HSKD", "LPKD") + final String code; + + /// Display name + final String name; + + /// Description + final String? description; + + /// Whether this is the default unit + final bool isDefault; + + const BusinessUnit({ + required this.id, + required this.code, + required this.name, + this.description, + this.isDefault = false, + }); + + /// Create from JSON map + factory BusinessUnit.fromJson(Map json) { + return BusinessUnit( + id: json['id'] as String, + code: json['code'] as String, + name: json['name'] as String, + description: json['description'] as String?, + isDefault: json['is_default'] as bool? ?? false, + ); + } + + /// Convert to JSON map + Map toJson() { + return { + 'id': id, + 'code': code, + 'name': name, + 'description': description, + 'is_default': isDefault, + }; + } + + /// Copy with method for immutability + BusinessUnit copyWith({ + String? id, + String? code, + String? name, + String? description, + bool? isDefault, + }) { + return BusinessUnit( + id: id ?? this.id, + code: code ?? this.code, + name: name ?? this.name, + description: description ?? this.description, + isDefault: isDefault ?? this.isDefault, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is BusinessUnit && + other.id == id && + other.code == code && + other.name == name && + other.description == description && + other.isDefault == isDefault; + } + + @override + int get hashCode { + return Object.hash(id, code, name, description, isDefault); + } + + @override + String toString() { + return 'BusinessUnit(id: $id, code: $code, name: $name, isDefault: $isDefault)'; + } +} diff --git a/lib/features/auth/presentation/pages/business_unit_selection_page.dart b/lib/features/auth/presentation/pages/business_unit_selection_page.dart new file mode 100644 index 0000000..7bc51e4 --- /dev/null +++ b/lib/features/auth/presentation/pages/business_unit_selection_page.dart @@ -0,0 +1,396 @@ +/// Business Unit Selection Page +/// +/// Allows users to select a business unit during registration or after login +/// when they have access to multiple units. +library; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/router/app_router.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/auth/domain/entities/business_unit.dart'; + +/// Business Unit Selection Page +/// +/// Flow: +/// 1. During registration: User selects unit before going to register page +/// 2. After login: User selects unit if they have multiple units +class BusinessUnitSelectionPage extends StatefulWidget { + const BusinessUnitSelectionPage({ + super.key, + this.businessUnits, + this.isRegistrationFlow = false, + this.onUnitSelected, + }); + + /// List of available business units + final List? businessUnits; + + /// Whether this is part of registration flow + final bool isRegistrationFlow; + + /// Callback when business unit is selected (for registration flow) + final void Function(BusinessUnit)? onUnitSelected; + + @override + State createState() => + _BusinessUnitSelectionPageState(); +} + +class _BusinessUnitSelectionPageState extends State { + BusinessUnit? _selectedUnit; + late List _availableUnits; + + @override + void initState() { + super.initState(); + // Use provided units or mock data for registration flow + _availableUnits = widget.businessUnits ?? _getMockBusinessUnits(); + } + + /// Mock business units for registration flow + /// TODO: Replace with actual API data when backend is ready + List _getMockBusinessUnits() { + return [ + const BusinessUnit( + id: '1', + code: 'VIKD', + name: 'VIKD', + description: 'Đơn vị kinh doanh VIKD', + ), + const BusinessUnit( + id: '2', + code: 'HSKD', + name: 'HSKD', + description: 'Đơn vị kinh doanh HSKD', + ), + const BusinessUnit( + id: '3', + code: 'LPKD', + name: 'LPKD', + description: 'Đơn vị kinh doanh LPKD', + ), + ]; + } + + void _handleContinue() { + if (_selectedUnit == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Vui lòng chọn đơn vị kinh doanh'), + backgroundColor: AppColors.danger, + ), + ); + return; + } + + if (widget.isRegistrationFlow) { + // Registration flow: pass selected unit to register page + widget.onUnitSelected?.call(_selectedUnit!); + context.pushNamed( + RouteNames.register, + extra: {'businessUnit': _selectedUnit}, + ); + } else { + // Login flow: save selected unit and navigate to home + // TODO: Save selected unit to local storage/state + context.goNamed(RouteNames.home); + } + } + + void _showInfoDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Đơn vị kinh doanh'), + content: const Text( + 'Chọn đơn vị kinh doanh mà bạn muốn truy cập. ' + 'Bạn có thể thay đổi đơn vị sau khi đăng nhập.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Đóng'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.white, + appBar: AppBar( + backgroundColor: AppColors.white, + elevation: AppBarSpecs.elevation, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => context.pop(), + ), + title: const Text( + 'Đơn vị kinh doanh', + style: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: false, + actions: [ + IconButton( + icon: const Icon(Icons.info_outline, color: Colors.black), + onPressed: _showInfoDialog, + ), + const SizedBox(width: AppSpacing.sm), + ], + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo Section + Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.primaryBlue, AppColors.lightBlue], + ), + borderRadius: BorderRadius.circular(20), + ), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'DBIZ', + style: TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.w700, + ), + ), + Text( + 'Worker App', + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ], + ), + ), + ), + + const SizedBox(height: AppSpacing.xl), + + // Welcome Message + const Text( + 'Chọn đơn vị kinh doanh để tiếp tục', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.grey500, fontSize: 14), + ), + + const SizedBox(height: 40), + + // Business Unit Selection List + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: Text( + 'Đơn vị kinh doanh', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + ), + ), + ), + const SizedBox(height: AppSpacing.md), + // Business Unit List Tiles + ...(_availableUnits.asMap().entries.map((entry) { + final index = entry.key; + final unit = entry.value; + final isSelected = _selectedUnit?.id == unit.id; + final isFirst = index == 0; + final isLast = index == _availableUnits.length - 1; + + return Container( + margin: EdgeInsets.only( + bottom: isLast ? 0 : AppSpacing.xs, + ), + decoration: BoxDecoration( + color: AppColors.white, + border: Border.all( + color: isSelected + ? AppColors.primaryBlue + : AppColors.grey100, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.vertical( + top: isFirst + ? const Radius.circular( + InputFieldSpecs.borderRadius, + ) + : Radius.zero, + bottom: isLast + ? const Radius.circular( + InputFieldSpecs.borderRadius, + ) + : Radius.zero, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primaryBlue.withValues( + alpha: 0.1, + ), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: InkWell( + onTap: () { + setState(() { + _selectedUnit = unit; + }); + }, + borderRadius: BorderRadius.vertical( + top: isFirst + ? const Radius.circular( + InputFieldSpecs.borderRadius, + ) + : Radius.zero, + bottom: isLast + ? const Radius.circular( + InputFieldSpecs.borderRadius, + ) + : Radius.zero, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: Row( + children: [ + // Icon + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isSelected + ? AppColors.primaryBlue.withValues( + alpha: 0.1, + ) + : AppColors.grey50, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.business, + color: isSelected + ? AppColors.primaryBlue + : AppColors.grey500, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.md), + // Unit Name + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + unit.name, + style: TextStyle( + fontSize: 16, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w500, + color: isSelected + ? AppColors.primaryBlue + : AppColors.grey900, + ), + ), + if (unit.description != null) ...[ + const SizedBox(height: 2), + Text( + unit.description!, + style: const TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + ), + ], + ], + ), + ), + // Radio indicator + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? AppColors.primaryBlue + : AppColors.grey500, + width: 2, + ), + color: isSelected + ? AppColors.primaryBlue + : Colors.transparent, + ), + child: isSelected + ? const Icon( + Icons.circle, + size: 10, + color: AppColors.white, + ) + : null, + ), + ], + ), + ), + ), + ); + }).toList()), + ], + ), + + const SizedBox(height: AppSpacing.xl), + + // Continue Button + SizedBox( + height: ButtonSpecs.height, + child: ElevatedButton( + onPressed: _handleContinue, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + ButtonSpecs.borderRadius, + ), + ), + ), + child: const Text( + 'Tiếp tục', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index cca6cc2..3957e61 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -115,15 +115,12 @@ class _LoginPageState extends ConsumerState { } } - /// Navigate to register page + /// Navigate to business unit selection (registration flow) void _navigateToRegister() { - // TODO: Navigate to register page when route is set up - // context.go('/register'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Chức năng đăng ký đang được phát triển'), - behavior: SnackBarBehavior.floating, - ), + // Navigate to business unit selection page first + context.pushNamed( + RouteNames.businessUnitSelection, + extra: {'isRegistrationFlow': true}, ); } diff --git a/lib/features/auth/presentation/pages/register_page.dart b/lib/features/auth/presentation/pages/register_page.dart index 48b423a..c441266 100644 --- a/lib/features/auth/presentation/pages/register_page.dart +++ b/lib/features/auth/presentation/pages/register_page.dart @@ -14,6 +14,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/utils/validators.dart'; +import 'package:worker/features/auth/domain/entities/business_unit.dart'; import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart'; import 'package:worker/features/auth/presentation/widgets/file_upload_card.dart'; import 'package:worker/features/auth/presentation/widgets/role_dropdown.dart'; @@ -29,10 +30,13 @@ import 'package:worker/features/auth/presentation/widgets/role_dropdown.dart'; /// - Terms and conditions checkbox /// /// Navigation: -/// - From: Login page +/// - From: Business unit selection page /// - To: OTP verification (broker/other) or pending approval (worker/dealer) class RegisterPage extends ConsumerStatefulWidget { - const RegisterPage({super.key}); + /// Selected business unit from previous screen + final BusinessUnit? selectedBusinessUnit; + + const RegisterPage({super.key, this.selectedBusinessUnit}); @override ConsumerState createState() => _RegisterPageState(); @@ -235,6 +239,18 @@ class _RegisterPageState extends ConsumerState { try { // TODO: Implement actual registration API call + // Include widget.selectedBusinessUnit?.id in the API request + // Example: + // final result = await authRepository.register( + // fullName: _fullNameController.text.trim(), + // phone: _phoneController.text.trim(), + // email: _emailController.text.trim(), + // password: _passwordController.text, + // role: _selectedRole, + // businessUnitId: widget.selectedBusinessUnit?.id, + // ... + // ); + // For now, simulate API delay await Future.delayed(const Duration(seconds: 2)); diff --git a/lib/hive_registrar.g.dart b/lib/hive_registrar.g.dart index a86bbc0..9556ff5 100644 --- a/lib/hive_registrar.g.dart +++ b/lib/hive_registrar.g.dart @@ -7,6 +7,7 @@ import 'package:worker/core/database/models/cached_data.dart'; import 'package:worker/core/database/models/enums.dart'; import 'package:worker/features/account/data/models/audit_log_model.dart'; import 'package:worker/features/account/data/models/payment_reminder_model.dart'; +import 'package:worker/features/auth/data/models/business_unit_model.dart'; import 'package:worker/features/auth/data/models/user_model.dart'; import 'package:worker/features/auth/data/models/user_session_model.dart'; import 'package:worker/features/cart/data/models/cart_item_model.dart'; @@ -37,6 +38,7 @@ import 'package:worker/features/showrooms/data/models/showroom_product_model.dar extension HiveRegistrar on HiveInterface { void registerAdapters() { registerAdapter(AuditLogModelAdapter()); + registerAdapter(BusinessUnitModelAdapter()); registerAdapter(CachedDataAdapter()); registerAdapter(CartItemModelAdapter()); registerAdapter(CartModelAdapter()); @@ -92,6 +94,7 @@ extension HiveRegistrar on HiveInterface { extension IsolatedHiveRegistrar on IsolatedHiveInterface { void registerAdapters() { registerAdapter(AuditLogModelAdapter()); + registerAdapter(BusinessUnitModelAdapter()); registerAdapter(CachedDataAdapter()); registerAdapter(CartItemModelAdapter()); registerAdapter(CartModelAdapter());