add start/business unit

This commit is contained in:
Phuoc Nguyen
2025-11-07 13:56:51 +07:00
parent 3803bd26e0
commit ce7396f729
9 changed files with 687 additions and 12 deletions

View File

@@ -124,6 +124,7 @@ class HiveTypeIds {
static const int promotionModel = 26; static const int promotionModel = 26;
static const int categoryModel = 27; static const int categoryModel = 27;
static const int favoriteModel = 28; static const int favoriteModel = 28;
static const int businessUnitModel = 29;
// Enums (30-59) // Enums (30-59)
static const int userRole = 30; static const int userRole = 30;

View File

@@ -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/addresses_page.dart';
import 'package:worker/features/account/presentation/pages/change_password_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/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/login_page.dart';
import 'package:worker/features/auth/presentation/pages/register_page.dart'; import 'package:worker/features/auth/presentation/pages/register_page.dart';
import 'package:worker/features/cart/presentation/pages/cart_page.dart'; import 'package:worker/features/cart/presentation/pages/cart_page.dart';
@@ -62,8 +64,30 @@ class AppRouter {
GoRoute( GoRoute(
path: RouteNames.register, path: RouteNames.register,
name: RouteNames.register, name: RouteNames.register,
pageBuilder: (context, state) => pageBuilder: (context, state) {
MaterialPage(key: state.pageKey, child: const RegisterPage()), final extra = state.extra as Map<String, dynamic>?;
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<String, dynamic>?;
return MaterialPage(
key: state.pageKey,
child: BusinessUnitSelectionPage(
businessUnits: extra?['businessUnits'] as List<BusinessUnit>?,
isRegistrationFlow:
(extra?['isRegistrationFlow'] as bool?) ?? false,
),
);
},
), ),
// Main Route (with bottom navigation) // Main Route (with bottom navigation)
@@ -447,6 +471,7 @@ class RouteNames {
static const String login = '/login'; static const String login = '/login';
static const String otpVerification = '/otp-verification'; static const String otpVerification = '/otp-verification';
static const String register = '/register'; static const String register = '/register';
static const String businessUnitSelection = '/business-unit-selection';
} }
/// Route Extensions /// Route Extensions

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'name': name,
'description': description,
'is_default': isDefault,
};
}
@override
String toString() {
return 'BusinessUnitModel(id: $id, code: $code, name: $name)';
}
}

View File

@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'business_unit_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class BusinessUnitModelAdapter extends TypeAdapter<BusinessUnitModel> {
@override
final typeId = 29;
@override
BusinessUnitModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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;
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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)';
}
}

View File

@@ -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<BusinessUnit>? 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<BusinessUnitSelectionPage> createState() =>
_BusinessUnitSelectionPageState();
}
class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
BusinessUnit? _selectedUnit;
late List<BusinessUnit> _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<BusinessUnit> _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<void>(
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),
),
),
),
],
),
),
),
);
}
}

View File

@@ -115,15 +115,12 @@ class _LoginPageState extends ConsumerState<LoginPage> {
} }
} }
/// Navigate to register page /// Navigate to business unit selection (registration flow)
void _navigateToRegister() { void _navigateToRegister() {
// TODO: Navigate to register page when route is set up // Navigate to business unit selection page first
// context.go('/register'); context.pushNamed(
ScaffoldMessenger.of(context).showSnackBar( RouteNames.businessUnitSelection,
const SnackBar( extra: {'isRegistrationFlow': true},
content: Text('Chức năng đăng ký đang được phát triển'),
behavior: SnackBarBehavior.floating,
),
); );
} }

View File

@@ -14,6 +14,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/utils/validators.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/phone_input_field.dart';
import 'package:worker/features/auth/presentation/widgets/file_upload_card.dart'; import 'package:worker/features/auth/presentation/widgets/file_upload_card.dart';
import 'package:worker/features/auth/presentation/widgets/role_dropdown.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 /// - Terms and conditions checkbox
/// ///
/// Navigation: /// Navigation:
/// - From: Login page /// - From: Business unit selection page
/// - To: OTP verification (broker/other) or pending approval (worker/dealer) /// - To: OTP verification (broker/other) or pending approval (worker/dealer)
class RegisterPage extends ConsumerStatefulWidget { class RegisterPage extends ConsumerStatefulWidget {
const RegisterPage({super.key}); /// Selected business unit from previous screen
final BusinessUnit? selectedBusinessUnit;
const RegisterPage({super.key, this.selectedBusinessUnit});
@override @override
ConsumerState<RegisterPage> createState() => _RegisterPageState(); ConsumerState<RegisterPage> createState() => _RegisterPageState();
@@ -235,6 +239,18 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
try { try {
// TODO: Implement actual registration API call // 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 // For now, simulate API delay
await Future.delayed(const Duration(seconds: 2)); await Future.delayed(const Duration(seconds: 2));

View File

@@ -7,6 +7,7 @@ import 'package:worker/core/database/models/cached_data.dart';
import 'package:worker/core/database/models/enums.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/audit_log_model.dart';
import 'package:worker/features/account/data/models/payment_reminder_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_model.dart';
import 'package:worker/features/auth/data/models/user_session_model.dart'; import 'package:worker/features/auth/data/models/user_session_model.dart';
import 'package:worker/features/cart/data/models/cart_item_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 { extension HiveRegistrar on HiveInterface {
void registerAdapters() { void registerAdapters() {
registerAdapter(AuditLogModelAdapter()); registerAdapter(AuditLogModelAdapter());
registerAdapter(BusinessUnitModelAdapter());
registerAdapter(CachedDataAdapter()); registerAdapter(CachedDataAdapter());
registerAdapter(CartItemModelAdapter()); registerAdapter(CartItemModelAdapter());
registerAdapter(CartModelAdapter()); registerAdapter(CartModelAdapter());
@@ -92,6 +94,7 @@ extension HiveRegistrar on HiveInterface {
extension IsolatedHiveRegistrar on IsolatedHiveInterface { extension IsolatedHiveRegistrar on IsolatedHiveInterface {
void registerAdapters() { void registerAdapters() {
registerAdapter(AuditLogModelAdapter()); registerAdapter(AuditLogModelAdapter());
registerAdapter(BusinessUnitModelAdapter());
registerAdapter(CachedDataAdapter()); registerAdapter(CachedDataAdapter());
registerAdapter(CartItemModelAdapter()); registerAdapter(CartItemModelAdapter());
registerAdapter(CartModelAdapter()); registerAdapter(CartModelAdapter());