add auth, format
This commit is contained in:
@@ -6,18 +6,39 @@ part 'audit_log_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.auditLogModel)
|
||||
class AuditLogModel extends HiveObject {
|
||||
AuditLogModel({required this.logId, required this.userId, required this.action, required this.entityType, required this.entityId, this.oldValue, this.newValue, this.ipAddress, this.userAgent, required this.timestamp});
|
||||
|
||||
@HiveField(0) final int logId;
|
||||
@HiveField(1) final String userId;
|
||||
@HiveField(2) final String action;
|
||||
@HiveField(3) final String entityType;
|
||||
@HiveField(4) final String entityId;
|
||||
@HiveField(5) final String? oldValue;
|
||||
@HiveField(6) final String? newValue;
|
||||
@HiveField(7) final String? ipAddress;
|
||||
@HiveField(8) final String? userAgent;
|
||||
@HiveField(9) final DateTime timestamp;
|
||||
AuditLogModel({
|
||||
required this.logId,
|
||||
required this.userId,
|
||||
required this.action,
|
||||
required this.entityType,
|
||||
required this.entityId,
|
||||
this.oldValue,
|
||||
this.newValue,
|
||||
this.ipAddress,
|
||||
this.userAgent,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
@HiveField(0)
|
||||
final int logId;
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
@HiveField(2)
|
||||
final String action;
|
||||
@HiveField(3)
|
||||
final String entityType;
|
||||
@HiveField(4)
|
||||
final String entityId;
|
||||
@HiveField(5)
|
||||
final String? oldValue;
|
||||
@HiveField(6)
|
||||
final String? newValue;
|
||||
@HiveField(7)
|
||||
final String? ipAddress;
|
||||
@HiveField(8)
|
||||
final String? userAgent;
|
||||
@HiveField(9)
|
||||
final DateTime timestamp;
|
||||
|
||||
factory AuditLogModel.fromJson(Map<String, dynamic> json) => AuditLogModel(
|
||||
logId: json['log_id'] as int,
|
||||
|
||||
@@ -6,33 +6,65 @@ part 'payment_reminder_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.paymentReminderModel)
|
||||
class PaymentReminderModel extends HiveObject {
|
||||
PaymentReminderModel({required this.reminderId, required this.invoiceId, required this.userId, required this.reminderType, required this.subject, required this.message, required this.isRead, required this.isSent, this.scheduledAt, this.sentAt, this.readAt});
|
||||
|
||||
@HiveField(0) final String reminderId;
|
||||
@HiveField(1) final String invoiceId;
|
||||
@HiveField(2) final String userId;
|
||||
@HiveField(3) final ReminderType reminderType;
|
||||
@HiveField(4) final String subject;
|
||||
@HiveField(5) final String message;
|
||||
@HiveField(6) final bool isRead;
|
||||
@HiveField(7) final bool isSent;
|
||||
@HiveField(8) final DateTime? scheduledAt;
|
||||
@HiveField(9) final DateTime? sentAt;
|
||||
@HiveField(10) final DateTime? readAt;
|
||||
PaymentReminderModel({
|
||||
required this.reminderId,
|
||||
required this.invoiceId,
|
||||
required this.userId,
|
||||
required this.reminderType,
|
||||
required this.subject,
|
||||
required this.message,
|
||||
required this.isRead,
|
||||
required this.isSent,
|
||||
this.scheduledAt,
|
||||
this.sentAt,
|
||||
this.readAt,
|
||||
});
|
||||
|
||||
factory PaymentReminderModel.fromJson(Map<String, dynamic> json) => PaymentReminderModel(
|
||||
reminderId: json['reminder_id'] as String,
|
||||
invoiceId: json['invoice_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
reminderType: ReminderType.values.firstWhere((e) => e.name == json['reminder_type']),
|
||||
subject: json['subject'] as String,
|
||||
message: json['message'] as String,
|
||||
isRead: json['is_read'] as bool? ?? false,
|
||||
isSent: json['is_sent'] as bool? ?? false,
|
||||
scheduledAt: json['scheduled_at'] != null ? DateTime.parse(json['scheduled_at']?.toString() ?? '') : null,
|
||||
sentAt: json['sent_at'] != null ? DateTime.parse(json['sent_at']?.toString() ?? '') : null,
|
||||
readAt: json['read_at'] != null ? DateTime.parse(json['read_at']?.toString() ?? '') : null,
|
||||
);
|
||||
@HiveField(0)
|
||||
final String reminderId;
|
||||
@HiveField(1)
|
||||
final String invoiceId;
|
||||
@HiveField(2)
|
||||
final String userId;
|
||||
@HiveField(3)
|
||||
final ReminderType reminderType;
|
||||
@HiveField(4)
|
||||
final String subject;
|
||||
@HiveField(5)
|
||||
final String message;
|
||||
@HiveField(6)
|
||||
final bool isRead;
|
||||
@HiveField(7)
|
||||
final bool isSent;
|
||||
@HiveField(8)
|
||||
final DateTime? scheduledAt;
|
||||
@HiveField(9)
|
||||
final DateTime? sentAt;
|
||||
@HiveField(10)
|
||||
final DateTime? readAt;
|
||||
|
||||
factory PaymentReminderModel.fromJson(Map<String, dynamic> json) =>
|
||||
PaymentReminderModel(
|
||||
reminderId: json['reminder_id'] as String,
|
||||
invoiceId: json['invoice_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
reminderType: ReminderType.values.firstWhere(
|
||||
(e) => e.name == json['reminder_type'],
|
||||
),
|
||||
subject: json['subject'] as String,
|
||||
message: json['message'] as String,
|
||||
isRead: json['is_read'] as bool? ?? false,
|
||||
isSent: json['is_sent'] as bool? ?? false,
|
||||
scheduledAt: json['scheduled_at'] != null
|
||||
? DateTime.parse(json['scheduled_at']?.toString() ?? '')
|
||||
: null,
|
||||
sentAt: json['sent_at'] != null
|
||||
? DateTime.parse(json['sent_at']?.toString() ?? '')
|
||||
: null,
|
||||
readAt: json['read_at'] != null
|
||||
? DateTime.parse(json['read_at']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'reminder_id': reminderId,
|
||||
|
||||
@@ -134,13 +134,7 @@ class AuditLog {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
logId,
|
||||
userId,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
);
|
||||
return Object.hash(logId, userId, action, entityType, entityId);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -59,10 +59,7 @@ class AccountMenuItem extends StatelessWidget {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.grey100,
|
||||
width: 1.0,
|
||||
),
|
||||
bottom: BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -72,7 +69,9 @@ class AccountMenuItem extends StatelessWidget {
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBackgroundColor ?? AppColors.lightBlue.withValues(alpha: 0.1),
|
||||
color:
|
||||
iconBackgroundColor ??
|
||||
AppColors.lightBlue.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
|
||||
122
lib/features/auth/data/datasources/auth_local_datasource.dart
Normal file
122
lib/features/auth/data/datasources/auth_local_datasource.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
/// Authentication Local Data Source
|
||||
///
|
||||
/// Handles secure local storage of authentication session data.
|
||||
/// Uses flutter_secure_storage for SID and CSRF token (encrypted).
|
||||
library;
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:worker/features/auth/data/models/auth_session_model.dart';
|
||||
|
||||
/// Authentication Local Data Source
|
||||
///
|
||||
/// Manages session data (SID, CSRF token) using secure storage.
|
||||
/// Session tokens are stored encrypted on device.
|
||||
class AuthLocalDataSource {
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
|
||||
/// Secure storage keys
|
||||
static const String _sidKey = 'auth_session_sid';
|
||||
static const String _csrfTokenKey = 'auth_session_csrf_token';
|
||||
static const String _fullNameKey = 'auth_session_full_name';
|
||||
static const String _createdAtKey = 'auth_session_created_at';
|
||||
static const String _appsKey = 'auth_session_apps';
|
||||
|
||||
AuthLocalDataSource(this._secureStorage);
|
||||
|
||||
/// Save session data securely
|
||||
///
|
||||
/// Stores SID, CSRF token, and user info in encrypted storage.
|
||||
Future<void> saveSession(SessionData session) async {
|
||||
await _secureStorage.write(key: _sidKey, value: session.sid);
|
||||
await _secureStorage.write(key: _csrfTokenKey, value: session.csrfToken);
|
||||
await _secureStorage.write(key: _fullNameKey, value: session.fullName);
|
||||
await _secureStorage.write(
|
||||
key: _createdAtKey,
|
||||
value: session.createdAt.toIso8601String(),
|
||||
);
|
||||
|
||||
// Store apps as JSON string if available
|
||||
if (session.apps != null && session.apps!.isNotEmpty) {
|
||||
final appsJson = session.apps!.map((app) => app.toJson()).toList();
|
||||
// Convert to JSON string for storage
|
||||
await _secureStorage.write(key: _appsKey, value: appsJson.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stored session data
|
||||
///
|
||||
/// Returns null if no session is stored.
|
||||
Future<SessionData?> getSession() async {
|
||||
final sid = await _secureStorage.read(key: _sidKey);
|
||||
final csrfToken = await _secureStorage.read(key: _csrfTokenKey);
|
||||
final fullName = await _secureStorage.read(key: _fullNameKey);
|
||||
final createdAtStr = await _secureStorage.read(key: _createdAtKey);
|
||||
|
||||
if (sid == null || csrfToken == null || fullName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final createdAt = createdAtStr != null
|
||||
? DateTime.tryParse(createdAtStr) ?? DateTime.now()
|
||||
: DateTime.now();
|
||||
|
||||
// TODO: Parse apps from JSON string if needed
|
||||
// For now, apps are optional
|
||||
|
||||
return SessionData(
|
||||
sid: sid,
|
||||
csrfToken: csrfToken,
|
||||
fullName: fullName,
|
||||
createdAt: createdAt,
|
||||
apps: null, // TODO: Parse from stored JSON if needed
|
||||
);
|
||||
}
|
||||
|
||||
/// Get SID (Session ID)
|
||||
///
|
||||
/// Returns null if not logged in.
|
||||
Future<String?> getSid() async {
|
||||
return await _secureStorage.read(key: _sidKey);
|
||||
}
|
||||
|
||||
/// Get CSRF Token
|
||||
///
|
||||
/// Returns null if not logged in.
|
||||
Future<String?> getCsrfToken() async {
|
||||
return await _secureStorage.read(key: _csrfTokenKey);
|
||||
}
|
||||
|
||||
/// Get Full Name
|
||||
///
|
||||
/// Returns null if not logged in.
|
||||
Future<String?> getFullName() async {
|
||||
return await _secureStorage.read(key: _fullNameKey);
|
||||
}
|
||||
|
||||
/// Check if user has valid session
|
||||
///
|
||||
/// Returns true if SID and CSRF token are present.
|
||||
Future<bool> hasValidSession() async {
|
||||
final sid = await getSid();
|
||||
final csrfToken = await getCsrfToken();
|
||||
return sid != null && csrfToken != null;
|
||||
}
|
||||
|
||||
/// Clear session data
|
||||
///
|
||||
/// Called during logout to remove all session information.
|
||||
Future<void> clearSession() async {
|
||||
await _secureStorage.delete(key: _sidKey);
|
||||
await _secureStorage.delete(key: _csrfTokenKey);
|
||||
await _secureStorage.delete(key: _fullNameKey);
|
||||
await _secureStorage.delete(key: _createdAtKey);
|
||||
await _secureStorage.delete(key: _appsKey);
|
||||
}
|
||||
|
||||
/// Clear all authentication data
|
||||
///
|
||||
/// Complete cleanup of all stored auth data.
|
||||
Future<void> clearAll() async {
|
||||
await _secureStorage.deleteAll();
|
||||
}
|
||||
}
|
||||
86
lib/features/auth/data/models/auth_session_model.dart
Normal file
86
lib/features/auth/data/models/auth_session_model.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
/// Authentication Session Model
|
||||
///
|
||||
/// Models for API authentication response structure.
|
||||
/// Matches the ERPNext login API response format.
|
||||
library;
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'auth_session_model.freezed.dart';
|
||||
part 'auth_session_model.g.dart';
|
||||
|
||||
/// App Information
|
||||
///
|
||||
/// Represents an available app in the system.
|
||||
@freezed
|
||||
sealed class AppInfo with _$AppInfo {
|
||||
const factory AppInfo({
|
||||
@JsonKey(name: 'app_title') required String appTitle,
|
||||
@JsonKey(name: 'app_endpoint') required String appEndpoint,
|
||||
@JsonKey(name: 'app_logo') required String appLogo,
|
||||
}) = _AppInfo;
|
||||
|
||||
factory AppInfo.fromJson(Map<String, dynamic> json) =>
|
||||
_$AppInfoFromJson(json);
|
||||
}
|
||||
|
||||
/// Login Response Message
|
||||
///
|
||||
/// Contains the core authentication data from login response.
|
||||
@freezed
|
||||
sealed class LoginMessage with _$LoginMessage {
|
||||
const factory LoginMessage({
|
||||
required bool success,
|
||||
required String message,
|
||||
required String sid,
|
||||
@JsonKey(name: 'csrf_token') required String csrfToken,
|
||||
@Default([]) List<AppInfo> apps,
|
||||
}) = _LoginMessage;
|
||||
|
||||
factory LoginMessage.fromJson(Map<String, dynamic> json) =>
|
||||
_$LoginMessageFromJson(json);
|
||||
}
|
||||
|
||||
/// Authentication Session Response
|
||||
///
|
||||
/// Complete authentication response from ERPNext login API.
|
||||
@freezed
|
||||
sealed class AuthSessionResponse with _$AuthSessionResponse {
|
||||
const factory AuthSessionResponse({
|
||||
@JsonKey(name: 'session_expired') required int sessionExpired,
|
||||
required LoginMessage message,
|
||||
@JsonKey(name: 'home_page') required String homePage,
|
||||
@JsonKey(name: 'full_name') required String fullName,
|
||||
}) = _AuthSessionResponse;
|
||||
|
||||
factory AuthSessionResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$AuthSessionResponseFromJson(json);
|
||||
}
|
||||
|
||||
/// Session Storage Model
|
||||
///
|
||||
/// Simplified model for storing session data in Hive.
|
||||
@freezed
|
||||
sealed class SessionData with _$SessionData {
|
||||
const factory SessionData({
|
||||
required String sid,
|
||||
required String csrfToken,
|
||||
required String fullName,
|
||||
required DateTime createdAt,
|
||||
List<AppInfo>? apps,
|
||||
}) = _SessionData;
|
||||
|
||||
factory SessionData.fromJson(Map<String, dynamic> json) =>
|
||||
_$SessionDataFromJson(json);
|
||||
|
||||
/// Create from API response
|
||||
factory SessionData.fromAuthResponse(AuthSessionResponse response) {
|
||||
return SessionData(
|
||||
sid: response.message.sid,
|
||||
csrfToken: response.message.csrfToken,
|
||||
fullName: response.fullName,
|
||||
createdAt: DateTime.now(),
|
||||
apps: response.message.apps,
|
||||
);
|
||||
}
|
||||
}
|
||||
1113
lib/features/auth/data/models/auth_session_model.freezed.dart
Normal file
1113
lib/features/auth/data/models/auth_session_model.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
131
lib/features/auth/data/models/auth_session_model.g.dart
Normal file
131
lib/features/auth/data/models/auth_session_model.g.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_session_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_AppInfo _$AppInfoFromJson(Map<String, dynamic> json) => $checkedCreate(
|
||||
'_AppInfo',
|
||||
json,
|
||||
($checkedConvert) {
|
||||
final val = _AppInfo(
|
||||
appTitle: $checkedConvert('app_title', (v) => v as String),
|
||||
appEndpoint: $checkedConvert('app_endpoint', (v) => v as String),
|
||||
appLogo: $checkedConvert('app_logo', (v) => v as String),
|
||||
);
|
||||
return val;
|
||||
},
|
||||
fieldKeyMap: const {
|
||||
'appTitle': 'app_title',
|
||||
'appEndpoint': 'app_endpoint',
|
||||
'appLogo': 'app_logo',
|
||||
},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppInfoToJson(_AppInfo instance) => <String, dynamic>{
|
||||
'app_title': instance.appTitle,
|
||||
'app_endpoint': instance.appEndpoint,
|
||||
'app_logo': instance.appLogo,
|
||||
};
|
||||
|
||||
_LoginMessage _$LoginMessageFromJson(Map<String, dynamic> json) =>
|
||||
$checkedCreate('_LoginMessage', json, ($checkedConvert) {
|
||||
final val = _LoginMessage(
|
||||
success: $checkedConvert('success', (v) => v as bool),
|
||||
message: $checkedConvert('message', (v) => v as String),
|
||||
sid: $checkedConvert('sid', (v) => v as String),
|
||||
csrfToken: $checkedConvert('csrf_token', (v) => v as String),
|
||||
apps: $checkedConvert(
|
||||
'apps',
|
||||
(v) =>
|
||||
(v as List<dynamic>?)
|
||||
?.map((e) => AppInfo.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
),
|
||||
);
|
||||
return val;
|
||||
}, fieldKeyMap: const {'csrfToken': 'csrf_token'});
|
||||
|
||||
Map<String, dynamic> _$LoginMessageToJson(_LoginMessage instance) =>
|
||||
<String, dynamic>{
|
||||
'success': instance.success,
|
||||
'message': instance.message,
|
||||
'sid': instance.sid,
|
||||
'csrf_token': instance.csrfToken,
|
||||
'apps': instance.apps.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
_AuthSessionResponse _$AuthSessionResponseFromJson(Map<String, dynamic> json) =>
|
||||
$checkedCreate(
|
||||
'_AuthSessionResponse',
|
||||
json,
|
||||
($checkedConvert) {
|
||||
final val = _AuthSessionResponse(
|
||||
sessionExpired: $checkedConvert(
|
||||
'session_expired',
|
||||
(v) => (v as num).toInt(),
|
||||
),
|
||||
message: $checkedConvert(
|
||||
'message',
|
||||
(v) => LoginMessage.fromJson(v as Map<String, dynamic>),
|
||||
),
|
||||
homePage: $checkedConvert('home_page', (v) => v as String),
|
||||
fullName: $checkedConvert('full_name', (v) => v as String),
|
||||
);
|
||||
return val;
|
||||
},
|
||||
fieldKeyMap: const {
|
||||
'sessionExpired': 'session_expired',
|
||||
'homePage': 'home_page',
|
||||
'fullName': 'full_name',
|
||||
},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AuthSessionResponseToJson(
|
||||
_AuthSessionResponse instance,
|
||||
) => <String, dynamic>{
|
||||
'session_expired': instance.sessionExpired,
|
||||
'message': instance.message.toJson(),
|
||||
'home_page': instance.homePage,
|
||||
'full_name': instance.fullName,
|
||||
};
|
||||
|
||||
_SessionData _$SessionDataFromJson(Map<String, dynamic> json) => $checkedCreate(
|
||||
'_SessionData',
|
||||
json,
|
||||
($checkedConvert) {
|
||||
final val = _SessionData(
|
||||
sid: $checkedConvert('sid', (v) => v as String),
|
||||
csrfToken: $checkedConvert('csrf_token', (v) => v as String),
|
||||
fullName: $checkedConvert('full_name', (v) => v as String),
|
||||
createdAt: $checkedConvert(
|
||||
'created_at',
|
||||
(v) => DateTime.parse(v as String),
|
||||
),
|
||||
apps: $checkedConvert(
|
||||
'apps',
|
||||
(v) => (v as List<dynamic>?)
|
||||
?.map((e) => AppInfo.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
return val;
|
||||
},
|
||||
fieldKeyMap: const {
|
||||
'csrfToken': 'csrf_token',
|
||||
'fullName': 'full_name',
|
||||
'createdAt': 'created_at',
|
||||
},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SessionDataToJson(_SessionData instance) =>
|
||||
<String, dynamic>{
|
||||
'sid': instance.sid,
|
||||
'csrf_token': instance.csrfToken,
|
||||
'full_name': instance.fullName,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'apps': ?instance.apps?.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
@@ -19,7 +19,7 @@ enum UserRole {
|
||||
accountant,
|
||||
|
||||
/// Designer
|
||||
designer;
|
||||
designer,
|
||||
}
|
||||
|
||||
/// User status enum
|
||||
@@ -34,7 +34,7 @@ enum UserStatus {
|
||||
suspended,
|
||||
|
||||
/// Rejected account
|
||||
rejected;
|
||||
rejected,
|
||||
}
|
||||
|
||||
/// Loyalty tier enum
|
||||
|
||||
492
lib/features/auth/presentation/pages/login_page.dart
Normal file
492
lib/features/auth/presentation/pages/login_page.dart
Normal file
@@ -0,0 +1,492 @@
|
||||
/// Login Page
|
||||
///
|
||||
/// Main authentication page for the Worker app.
|
||||
/// Allows users to login with phone number and password.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/core/utils/validators.dart';
|
||||
import 'package:worker/features/auth/presentation/providers/auth_provider.dart';
|
||||
import 'package:worker/features/auth/presentation/providers/password_visibility_provider.dart';
|
||||
import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart';
|
||||
|
||||
/// Login Page
|
||||
///
|
||||
/// Provides phone and password authentication.
|
||||
/// On successful login, navigates to home page.
|
||||
/// Links to registration page for new users.
|
||||
///
|
||||
/// Features:
|
||||
/// - Phone number input with Vietnamese format validation
|
||||
/// - Password input with visibility toggle
|
||||
/// - Form validation
|
||||
/// - Loading states
|
||||
/// - Error handling with snackbar
|
||||
/// - Link to registration
|
||||
/// - Customer support link
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
// Form key for validation
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers
|
||||
final _phoneController = TextEditingController(text: "0988111111");
|
||||
final _passwordController = TextEditingController(text: "123456");
|
||||
|
||||
// Focus nodes
|
||||
final _phoneFocusNode = FocusNode();
|
||||
final _passwordFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_passwordController.dispose();
|
||||
_phoneFocusNode.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Handle login button press
|
||||
Future<void> _handleLogin() async {
|
||||
// Validate form
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Unfocus keyboard
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
try {
|
||||
// Call login method
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.login(
|
||||
phoneNumber: _phoneController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
);
|
||||
|
||||
// Check if login was successful
|
||||
final authState = ref.read(authProvider);
|
||||
authState.when(
|
||||
data: (user) {
|
||||
if (user != null && mounted) {
|
||||
// Navigate to home on success
|
||||
context.goHome();
|
||||
}
|
||||
},
|
||||
loading: () {},
|
||||
error: (error, stack) {
|
||||
// Show error snackbar
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error.toString()),
|
||||
backgroundColor: AppColors.danger,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Show error snackbar
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Đăng nhập thất bại: ${e.toString()}'),
|
||||
backgroundColor: AppColors.danger,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to register page
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show support dialog
|
||||
void _showSupport() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Hỗ trợ khách hàng'),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Hotline: 1900 xxxx'),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
Text('Email: support@eurotile.vn'),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
Text('Giờ làm việc: 8:00 - 17:00 (T2-T6)'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Đóng'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch auth state for loading indicator
|
||||
final authState = ref.watch(authProvider);
|
||||
final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
// Logo Section
|
||||
_buildLogo(),
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
// Welcome Message
|
||||
_buildWelcomeMessage(),
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
// Login Form Card
|
||||
_buildLoginForm(authState, isPasswordVisible),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Register Link
|
||||
_buildRegisterLink(),
|
||||
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
|
||||
// Support Link
|
||||
_buildSupportLink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build logo section
|
||||
Widget _buildLogo() {
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 20.0),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.primaryBlue, AppColors.lightBlue],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20.0),
|
||||
),
|
||||
child: const Column(
|
||||
children: [
|
||||
Text(
|
||||
'EUROTILE',
|
||||
style: TextStyle(
|
||||
color: AppColors.white,
|
||||
fontSize: 32.0,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.0),
|
||||
Text(
|
||||
'Worker App',
|
||||
style: TextStyle(
|
||||
color: AppColors.white,
|
||||
fontSize: 12.0,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build welcome message
|
||||
Widget _buildWelcomeMessage() {
|
||||
return const Column(
|
||||
children: [
|
||||
Text(
|
||||
'Xin chào!',
|
||||
style: TextStyle(
|
||||
fontSize: 32.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'Đăng nhập để tiếp tục',
|
||||
style: TextStyle(fontSize: 16.0, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Build login form card
|
||||
Widget _buildLoginForm(
|
||||
AsyncValue<dynamic> authState,
|
||||
bool isPasswordVisible,
|
||||
) {
|
||||
final isLoading = authState.isLoading;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10.0,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Phone Input
|
||||
PhoneInputField(
|
||||
controller: _phoneController,
|
||||
focusNode: _phoneFocusNode,
|
||||
validator: Validators.phone,
|
||||
enabled: !isLoading,
|
||||
onFieldSubmitted: (_) {
|
||||
// Move focus to password field
|
||||
FocusScope.of(context).requestFocus(_passwordFocusNode);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Password Input
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
enabled: !isLoading,
|
||||
obscureText: !isPasswordVisible,
|
||||
textInputAction: TextInputAction.done,
|
||||
style: const TextStyle(
|
||||
fontSize: InputFieldSpecs.fontSize,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Mật khẩu',
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.labelFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
hintText: 'Nhập mật khẩu',
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.hintFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.lock,
|
||||
color: AppColors.primaryBlue,
|
||||
size: AppIconSize.md,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
color: AppColors.grey500,
|
||||
size: AppIconSize.md,
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
},
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.grey100,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.grey100,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.primaryBlue,
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.danger,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.danger,
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
errorStyle: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
),
|
||||
validator: (value) =>
|
||||
Validators.passwordSimple(value, minLength: 6),
|
||||
onFieldSubmitted: (_) {
|
||||
if (!isLoading) {
|
||||
_handleLogin();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Login Button
|
||||
SizedBox(
|
||||
height: ButtonSpecs.height,
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : _handleLogin,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: AppColors.white,
|
||||
disabledBackgroundColor: AppColors.grey100,
|
||||
disabledForegroundColor: AppColors.grey500,
|
||||
elevation: ButtonSpecs.elevation,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ButtonSpecs.borderRadius),
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
height: 20.0,
|
||||
width: 20.0,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Đăng nhập',
|
||||
style: TextStyle(
|
||||
fontSize: ButtonSpecs.fontSize,
|
||||
fontWeight: ButtonSpecs.fontWeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build register link
|
||||
Widget _buildRegisterLink() {
|
||||
return Center(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: 'Chưa có tài khoản? ',
|
||||
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: GestureDetector(
|
||||
onTap: _navigateToRegister,
|
||||
child: const Text(
|
||||
'Đăng ký ngay',
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build support link
|
||||
Widget _buildSupportLink() {
|
||||
return Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: _showSupport,
|
||||
icon: const Icon(
|
||||
Icons.headset_mic,
|
||||
size: AppIconSize.sm,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
label: const Text(
|
||||
'Hỗ trợ khách hàng',
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
790
lib/features/auth/presentation/pages/register_page.dart
Normal file
790
lib/features/auth/presentation/pages/register_page.dart
Normal file
@@ -0,0 +1,790 @@
|
||||
/// Registration Page
|
||||
///
|
||||
/// User registration form with role-based verification requirements.
|
||||
/// Matches design from html/register.html
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
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/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';
|
||||
|
||||
/// Registration Page
|
||||
///
|
||||
/// Features:
|
||||
/// - Full name, phone, email, password fields
|
||||
/// - Role selection (dealer/worker/broker/other)
|
||||
/// - Conditional verification section for workers/dealers
|
||||
/// - File upload for ID card and certificate
|
||||
/// - Company name and city selection
|
||||
/// - Terms and conditions checkbox
|
||||
///
|
||||
/// Navigation:
|
||||
/// - From: Login page
|
||||
/// - To: OTP verification (broker/other) or pending approval (worker/dealer)
|
||||
class RegisterPage extends ConsumerStatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
// Form key
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Text controllers
|
||||
final _fullNameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _idNumberController = TextEditingController();
|
||||
final _taxCodeController = TextEditingController();
|
||||
final _companyController = TextEditingController();
|
||||
|
||||
// Focus nodes
|
||||
final _fullNameFocus = FocusNode();
|
||||
final _phoneFocus = FocusNode();
|
||||
final _emailFocus = FocusNode();
|
||||
final _passwordFocus = FocusNode();
|
||||
final _idNumberFocus = FocusNode();
|
||||
final _taxCodeFocus = FocusNode();
|
||||
final _companyFocus = FocusNode();
|
||||
|
||||
// State
|
||||
String? _selectedRole;
|
||||
String? _selectedCity;
|
||||
File? _idCardFile;
|
||||
File? _certificateFile;
|
||||
bool _termsAccepted = false;
|
||||
bool _passwordVisible = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
final _imagePicker = ImagePicker();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fullNameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_idNumberController.dispose();
|
||||
_taxCodeController.dispose();
|
||||
_companyController.dispose();
|
||||
_fullNameFocus.dispose();
|
||||
_phoneFocus.dispose();
|
||||
_emailFocus.dispose();
|
||||
_passwordFocus.dispose();
|
||||
_idNumberFocus.dispose();
|
||||
_taxCodeFocus.dispose();
|
||||
_companyFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Check if verification section should be shown
|
||||
bool get _shouldShowVerification {
|
||||
return _selectedRole == 'worker' || _selectedRole == 'dealer';
|
||||
}
|
||||
|
||||
/// Pick image from gallery or camera
|
||||
Future<void> _pickImage(bool isIdCard) async {
|
||||
try {
|
||||
// Show bottom sheet to select source
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt),
|
||||
title: const Text('Chụp ảnh'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library),
|
||||
title: const Text('Chọn từ thư viện'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (source == null) return;
|
||||
|
||||
final pickedFile = await _imagePicker.pickImage(
|
||||
source: source,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (pickedFile == null) return;
|
||||
|
||||
final file = File(pickedFile.path);
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
final fileSize = await file.length();
|
||||
if (fileSize > 5 * 1024 * 1024) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('File không được vượt quá 5MB'),
|
||||
backgroundColor: AppColors.danger,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (isIdCard) {
|
||||
_idCardFile = file;
|
||||
} else {
|
||||
_certificateFile = file;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Lỗi chọn ảnh: $e'),
|
||||
backgroundColor: AppColors.danger,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove selected image
|
||||
void _removeImage(bool isIdCard) {
|
||||
setState(() {
|
||||
if (isIdCard) {
|
||||
_idCardFile = null;
|
||||
} else {
|
||||
_certificateFile = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Validate form and submit
|
||||
Future<void> _handleRegister() async {
|
||||
// Validate form
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check terms acceptance
|
||||
if (!_termsAccepted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Vui lòng đồng ý với Điều khoản sử dụng và Chính sách bảo mật',
|
||||
),
|
||||
backgroundColor: AppColors.warning,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate verification requirements for workers/dealers
|
||||
if (_shouldShowVerification) {
|
||||
if (_idNumberController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vui lòng nhập số CCCD/CMND'),
|
||||
backgroundColor: AppColors.warning,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_idCardFile == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vui lòng tải lên ảnh CCCD/CMND'),
|
||||
backgroundColor: AppColors.warning,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_certificateFile == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vui lòng tải lên ảnh chứng chỉ hành nghề hoặc GPKD'),
|
||||
backgroundColor: AppColors.warning,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// TODO: Implement actual registration API call
|
||||
// For now, simulate API delay
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
if (mounted) {
|
||||
// Navigate based on role
|
||||
if (_shouldShowVerification) {
|
||||
// For workers/dealers with verification, show pending page
|
||||
// TODO: Navigate to pending approval page
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Đăng ký thành công! Tài khoản đang chờ xét duyệt.',
|
||||
),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
} else {
|
||||
// For other roles, navigate to OTP verification
|
||||
// TODO: Navigate to OTP verification page
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Đăng ký thành công! Vui lòng xác thực OTP.'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
// context.push('/otp-verification');
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Đăng ký thất bại: $e'),
|
||||
backgroundColor: AppColors.danger,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
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(
|
||||
'Đăng ký tài khoản',
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: false,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Welcome section
|
||||
const Text(
|
||||
'Tạo tài khoản mới',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Text(
|
||||
'Điền thông tin để bắt đầu',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Form card
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Full Name
|
||||
_buildLabel('Họ và tên *'),
|
||||
TextFormField(
|
||||
controller: _fullNameController,
|
||||
focusNode: _fullNameFocus,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Nhập họ và tên',
|
||||
prefixIcon: Icons.person,
|
||||
),
|
||||
validator: (value) => Validators.minLength(
|
||||
value,
|
||||
3,
|
||||
fieldName: 'Họ và tên',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Phone Number
|
||||
_buildLabel('Số điện thoại *'),
|
||||
PhoneInputField(
|
||||
controller: _phoneController,
|
||||
focusNode: _phoneFocus,
|
||||
validator: Validators.phone,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Email
|
||||
_buildLabel('Email *'),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
focusNode: _emailFocus,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Nhập email',
|
||||
prefixIcon: Icons.email,
|
||||
),
|
||||
validator: Validators.email,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Password
|
||||
_buildLabel('Mật khẩu *'),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
obscureText: !_passwordVisible,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Tạo mật khẩu mới',
|
||||
prefixIcon: Icons.lock,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_passwordVisible
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_passwordVisible = !_passwordVisible;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) =>
|
||||
Validators.passwordSimple(value, minLength: 6),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Text(
|
||||
'Mật khẩu tối thiểu 6 ký tự',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Role Selection
|
||||
_buildLabel('Vai trò *'),
|
||||
RoleDropdown(
|
||||
value: _selectedRole,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedRole = value;
|
||||
// Clear verification fields when role changes
|
||||
if (!_shouldShowVerification) {
|
||||
_idNumberController.clear();
|
||||
_taxCodeController.clear();
|
||||
_idCardFile = null;
|
||||
_certificateFile = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Vui lòng chọn vai trò';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Verification Section (conditional)
|
||||
if (_shouldShowVerification) ...[
|
||||
_buildVerificationSection(),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
],
|
||||
|
||||
// Company Name (optional)
|
||||
_buildLabel('Tên công ty/Cửa hàng'),
|
||||
TextFormField(
|
||||
controller: _companyController,
|
||||
focusNode: _companyFocus,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Nhập tên công ty (không bắt buộc)',
|
||||
prefixIcon: Icons.business,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// City/Province
|
||||
_buildLabel('Tỉnh/Thành phố *'),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedCity,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Chọn tỉnh/thành phố',
|
||||
prefixIcon: Icons.location_city,
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'hanoi',
|
||||
child: Text('Hà Nội'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'hcm',
|
||||
child: Text('TP. Hồ Chí Minh'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'danang',
|
||||
child: Text('Đà Nẵng'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'haiphong',
|
||||
child: Text('Hải Phòng'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'cantho',
|
||||
child: Text('Cần Thơ'),
|
||||
),
|
||||
DropdownMenuItem(value: 'other', child: Text('Khác')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedCity = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Vui lòng chọn tỉnh/thành phố';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Terms and Conditions
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _termsAccepted,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_termsAccepted = value ?? false;
|
||||
});
|
||||
},
|
||||
activeColor: AppColors.primaryBlue,
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_termsAccepted = !_termsAccepted;
|
||||
});
|
||||
},
|
||||
child: const Text.rich(
|
||||
TextSpan(
|
||||
text: 'Tôi đồng ý với ',
|
||||
style: TextStyle(fontSize: 13),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Điều khoản sử dụng',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
TextSpan(text: ' và '),
|
||||
TextSpan(
|
||||
text: 'Chính sách bảo mật',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Register Button
|
||||
SizedBox(
|
||||
height: ButtonSpecs.height,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleRegister,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: AppColors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
ButtonSpecs.borderRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Đăng ký',
|
||||
style: TextStyle(
|
||||
fontSize: ButtonSpecs.fontSize,
|
||||
fontWeight: ButtonSpecs.fontWeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Login Link
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Đã có tài khoản? ',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => context.pop(),
|
||||
child: const Text(
|
||||
'Đăng nhập',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build label widget
|
||||
Widget _buildLabel(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build input decoration
|
||||
InputDecoration _buildInputDecoration({
|
||||
required String hintText,
|
||||
required IconData prefixIcon,
|
||||
Widget? suffixIcon,
|
||||
}) {
|
||||
return InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.hintFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
prefixIcon,
|
||||
color: AppColors.primaryBlue,
|
||||
size: AppIconSize.md,
|
||||
),
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2.0),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 1.0),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 2.0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build verification section
|
||||
Widget _buildVerificationSection() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0), width: 2),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.shield, color: AppColors.primaryBlue, size: 20),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
const Text(
|
||||
'Thông tin xác thực',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Text(
|
||||
'Thông tin này sẽ được dùng để xác minh tư cách chuyên môn của bạn',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// ID Number
|
||||
_buildLabel('Số CCCD/CMND'),
|
||||
TextFormField(
|
||||
controller: _idNumberController,
|
||||
focusNode: _idNumberFocus,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Nhập số CCCD/CMND',
|
||||
prefixIcon: Icons.badge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Tax Code
|
||||
_buildLabel('Mã số thuế cá nhân/Công ty'),
|
||||
TextFormField(
|
||||
controller: _taxCodeController,
|
||||
focusNode: _taxCodeFocus,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: _buildInputDecoration(
|
||||
hintText: 'Nhập mã số thuế (không bắt buộc)',
|
||||
prefixIcon: Icons.receipt_long,
|
||||
),
|
||||
validator: Validators.taxIdOptional,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// ID Card Upload
|
||||
_buildLabel('Ảnh mặt trước CCCD/CMND'),
|
||||
FileUploadCard(
|
||||
file: _idCardFile,
|
||||
onTap: () => _pickImage(true),
|
||||
onRemove: () => _removeImage(true),
|
||||
icon: Icons.camera_alt,
|
||||
title: 'Chụp ảnh hoặc chọn file',
|
||||
subtitle: 'JPG, PNG tối đa 5MB',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Certificate Upload
|
||||
_buildLabel('Ảnh chứng chỉ hành nghề hoặc GPKD'),
|
||||
FileUploadCard(
|
||||
file: _certificateFile,
|
||||
onTap: () => _pickImage(false),
|
||||
onRemove: () => _removeImage(false),
|
||||
icon: Icons.file_present,
|
||||
title: 'Chụp ảnh hoặc chọn file',
|
||||
subtitle: 'JPG, PNG tối đa 5MB',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
279
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
279
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
/// Authentication State Provider
|
||||
///
|
||||
/// Manages authentication state for the Worker application.
|
||||
/// Handles login, logout, and user session management.
|
||||
///
|
||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||
library;
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
|
||||
import 'package:worker/features/auth/data/models/auth_session_model.dart';
|
||||
import 'package:worker/features/auth/domain/entities/user.dart';
|
||||
|
||||
part 'auth_provider.g.dart';
|
||||
|
||||
/// Provide FlutterSecureStorage instance
|
||||
@riverpod
|
||||
FlutterSecureStorage secureStorage(Ref ref) {
|
||||
return const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
}
|
||||
|
||||
/// Provide AuthLocalDataSource instance
|
||||
@riverpod
|
||||
AuthLocalDataSource authLocalDataSource(Ref ref) {
|
||||
final secureStorage = ref.watch(secureStorageProvider);
|
||||
return AuthLocalDataSource(secureStorage);
|
||||
}
|
||||
|
||||
/// Authentication state result
|
||||
///
|
||||
/// Represents the result of authentication operations.
|
||||
/// Contains either the authenticated user or null if logged out.
|
||||
typedef AuthState = AsyncValue<User?>;
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
/// Provides login and logout functionality with async state handling.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final authState = ref.watch(authProvider);
|
||||
/// authState.when(
|
||||
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
@riverpod
|
||||
class Auth extends _$Auth {
|
||||
/// Get auth local data source
|
||||
AuthLocalDataSource get _localDataSource =>
|
||||
ref.read(authLocalDataSourceProvider);
|
||||
|
||||
/// Initialize with saved session if available
|
||||
@override
|
||||
Future<User?> build() async {
|
||||
// Check for saved session in secure storage
|
||||
final session = await _localDataSource.getSession();
|
||||
if (session != null) {
|
||||
// User has saved session, create User entity
|
||||
final now = DateTime.now();
|
||||
return User(
|
||||
userId: 'user_saved', // TODO: Get from API
|
||||
phoneNumber: '', // TODO: Get from saved user data
|
||||
fullName: session.fullName,
|
||||
email: '', // TODO: Get from saved user data
|
||||
role: UserRole.customer,
|
||||
status: UserStatus.active,
|
||||
loyaltyTier: LoyaltyTier.gold,
|
||||
totalPoints: 0,
|
||||
companyInfo: null,
|
||||
cccd: null,
|
||||
attachments: [],
|
||||
address: null,
|
||||
avatarUrl: null,
|
||||
referralCode: null,
|
||||
referredBy: null,
|
||||
erpnextCustomerId: null,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: now,
|
||||
lastLoginAt: now,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Login with phone number and password
|
||||
///
|
||||
/// Simulates ERPNext API authentication with mock response.
|
||||
/// Stores session data (SID, CSRF token) in Hive.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [phoneNumber]: User's phone number (Vietnamese format)
|
||||
/// - [password]: User's password
|
||||
///
|
||||
/// Returns: Authenticated User object on success
|
||||
///
|
||||
/// Throws: Exception on authentication failure
|
||||
Future<void> login({
|
||||
required String phoneNumber,
|
||||
required String password,
|
||||
}) async {
|
||||
// Set loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
// Simulate API call delay
|
||||
state = await AsyncValue.guard(() async {
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
|
||||
// Mock validation
|
||||
if (phoneNumber.isEmpty || password.isEmpty) {
|
||||
throw Exception('Số điện thoại và mật khẩu không được để trống');
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
throw Exception('Mật khẩu phải có ít nhất 6 ký tự');
|
||||
}
|
||||
|
||||
// Simulate API response matching ERPNext format
|
||||
final mockApiResponse = AuthSessionResponse(
|
||||
sessionExpired: 1,
|
||||
message: const LoginMessage(
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
sid: 'df7fd4e7ef1041aa3422b0ee861315ba8c28d4fe008a7d7e0e7e0e01',
|
||||
csrfToken: '6b6e37563854e951c36a7af4177956bb15ca469ca4f498b742648d70',
|
||||
apps: [
|
||||
AppInfo(
|
||||
appTitle: 'App nhân viên kinh doanh',
|
||||
appEndpoint: '/ecommerce/app-sales',
|
||||
appLogo:
|
||||
'https://assets.digitalbiz.com.vn/DBIZ_Internal/Logo/logo_app_sales.png',
|
||||
),
|
||||
],
|
||||
),
|
||||
homePage: '/apps',
|
||||
fullName: 'Tân Duy Nguyễn',
|
||||
);
|
||||
|
||||
// Save session data to Hive
|
||||
final sessionData = SessionData.fromAuthResponse(mockApiResponse);
|
||||
await _localDataSource.saveSession(sessionData);
|
||||
|
||||
// Create and return User entity
|
||||
final now = DateTime.now();
|
||||
return User(
|
||||
userId: 'user_${phoneNumber.replaceAll('+84', '')}',
|
||||
phoneNumber: phoneNumber,
|
||||
fullName: mockApiResponse.fullName,
|
||||
email: 'user@eurotile.vn',
|
||||
role: UserRole.customer,
|
||||
status: UserStatus.active,
|
||||
loyaltyTier: LoyaltyTier.gold,
|
||||
totalPoints: 1500,
|
||||
companyInfo: const CompanyInfo(
|
||||
name: 'Công ty TNHH XYZ',
|
||||
taxId: '0123456789',
|
||||
businessType: 'Xây dựng',
|
||||
),
|
||||
cccd: '001234567890',
|
||||
attachments: [],
|
||||
address: '123 Đường ABC, Quận 1, TP.HCM',
|
||||
avatarUrl: null,
|
||||
referralCode: 'REF${phoneNumber.replaceAll('+84', '').substring(0, 6)}',
|
||||
referredBy: null,
|
||||
erpnextCustomerId: null,
|
||||
createdAt: now.subtract(const Duration(days: 30)),
|
||||
updatedAt: now,
|
||||
lastLoginAt: now,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Logout current user
|
||||
///
|
||||
/// Clears authentication state and removes saved session from Hive.
|
||||
Future<void> logout() async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
// Clear saved session from Hive
|
||||
await _localDataSource.clearSession();
|
||||
|
||||
// TODO: Call logout API to invalidate token on server
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Return null to indicate logged out
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Get current authenticated user
|
||||
///
|
||||
/// Returns the current user if logged in, null otherwise.
|
||||
User? get currentUser => state.value;
|
||||
|
||||
/// Check if user is authenticated
|
||||
///
|
||||
/// Returns true if there is a logged-in user.
|
||||
bool get isAuthenticated => currentUser != null;
|
||||
|
||||
/// Check if authentication is in progress
|
||||
///
|
||||
/// Returns true during login/logout operations.
|
||||
bool get isLoading => state.isLoading;
|
||||
|
||||
/// Get authentication error if any
|
||||
///
|
||||
/// Returns error message or null if no error.
|
||||
Object? get error => state.error;
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if user is authenticated
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
|
||||
/// if (isLoggedIn) {
|
||||
/// // Show home screen
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
bool isAuthenticated(Ref ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
return authState.value != null;
|
||||
}
|
||||
|
||||
/// Convenience provider for getting current user
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final user = ref.watch(currentUserProvider);
|
||||
/// if (user != null) {
|
||||
/// Text('Welcome ${user.fullName}');
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
User? currentUser(Ref ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
return authState.value;
|
||||
}
|
||||
|
||||
/// Convenience provider for user's loyalty tier
|
||||
///
|
||||
/// Returns the current user's loyalty tier or null if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final tier = ref.watch(userLoyaltyTierProvider);
|
||||
/// if (tier != null) {
|
||||
/// Text('Tier: ${tier.displayName}');
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
LoyaltyTier? userLoyaltyTier(Ref ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
return user?.loyaltyTier;
|
||||
}
|
||||
|
||||
/// Convenience provider for user's total points
|
||||
///
|
||||
/// Returns the current user's total loyalty points or 0 if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final points = ref.watch(userTotalPointsProvider);
|
||||
/// Text('Points: $points');
|
||||
/// ```
|
||||
@riverpod
|
||||
int userTotalPoints(Ref ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
return user?.totalPoints ?? 0;
|
||||
}
|
||||
500
lib/features/auth/presentation/providers/auth_provider.g.dart
Normal file
500
lib/features/auth/presentation/providers/auth_provider.g.dart
Normal file
@@ -0,0 +1,500 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provide FlutterSecureStorage instance
|
||||
|
||||
@ProviderFor(secureStorage)
|
||||
const secureStorageProvider = SecureStorageProvider._();
|
||||
|
||||
/// Provide FlutterSecureStorage instance
|
||||
|
||||
final class SecureStorageProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
FlutterSecureStorage,
|
||||
FlutterSecureStorage,
|
||||
FlutterSecureStorage
|
||||
>
|
||||
with $Provider<FlutterSecureStorage> {
|
||||
/// Provide FlutterSecureStorage instance
|
||||
const SecureStorageProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'secureStorageProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$secureStorageHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<FlutterSecureStorage> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FlutterSecureStorage create(Ref ref) {
|
||||
return secureStorage(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(FlutterSecureStorage value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<FlutterSecureStorage>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$secureStorageHash() => r'c3d90388f6d1bb7c95a29ceeda2e56c57deb1ecb';
|
||||
|
||||
/// Provide AuthLocalDataSource instance
|
||||
|
||||
@ProviderFor(authLocalDataSource)
|
||||
const authLocalDataSourceProvider = AuthLocalDataSourceProvider._();
|
||||
|
||||
/// Provide AuthLocalDataSource instance
|
||||
|
||||
final class AuthLocalDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AuthLocalDataSource,
|
||||
AuthLocalDataSource,
|
||||
AuthLocalDataSource
|
||||
>
|
||||
with $Provider<AuthLocalDataSource> {
|
||||
/// Provide AuthLocalDataSource instance
|
||||
const AuthLocalDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authLocalDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authLocalDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AuthLocalDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AuthLocalDataSource create(Ref ref) {
|
||||
return authLocalDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AuthLocalDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AuthLocalDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authLocalDataSourceHash() =>
|
||||
r'f104de00a8ab431f6736387fb499c2b6e0ab4924';
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
/// Provides login and logout functionality with async state handling.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final authState = ref.watch(authProvider);
|
||||
/// authState.when(
|
||||
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(Auth)
|
||||
const authProvider = AuthProvider._();
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
/// Provides login and logout functionality with async state handling.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final authState = ref.watch(authProvider);
|
||||
/// authState.when(
|
||||
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
/// Provides login and logout functionality with async state handling.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final authState = ref.watch(authProvider);
|
||||
/// authState.when(
|
||||
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
const AuthProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
Auth create() => Auth();
|
||||
}
|
||||
|
||||
String _$authHash() => r'6f410d1abe6c53a6cbfa52fde7ea7a2d22a7f78d';
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
/// Provides login and logout functionality with async state handling.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final authState = ref.watch(authProvider);
|
||||
/// authState.when(
|
||||
/// data: (user) => user != null ? HomeScreen() : LoginScreen(),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
abstract class _$Auth extends $AsyncNotifier<User?> {
|
||||
FutureOr<User?> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<AsyncValue<User?>, User?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<User?>, User?>,
|
||||
AsyncValue<User?>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if user is authenticated
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
|
||||
/// if (isLoggedIn) {
|
||||
/// // Show home screen
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(isAuthenticated)
|
||||
const isAuthenticatedProvider = IsAuthenticatedProvider._();
|
||||
|
||||
/// Convenience provider for checking if user is authenticated
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
|
||||
/// if (isLoggedIn) {
|
||||
/// // Show home screen
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class IsAuthenticatedProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Convenience provider for checking if user is authenticated
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isLoggedIn = ref.watch(isAuthenticatedProvider);
|
||||
/// if (isLoggedIn) {
|
||||
/// // Show home screen
|
||||
/// }
|
||||
/// ```
|
||||
const IsAuthenticatedProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'isAuthenticatedProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$isAuthenticatedHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return isAuthenticated(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$isAuthenticatedHash() => r'dc783f052ad2ddb7fa18c58e5dc6d212e6c32a96';
|
||||
|
||||
/// Convenience provider for getting current user
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final user = ref.watch(currentUserProvider);
|
||||
/// if (user != null) {
|
||||
/// Text('Welcome ${user.fullName}');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(currentUser)
|
||||
const currentUserProvider = CurrentUserProvider._();
|
||||
|
||||
/// Convenience provider for getting current user
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final user = ref.watch(currentUserProvider);
|
||||
/// if (user != null) {
|
||||
/// Text('Welcome ${user.fullName}');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class CurrentUserProvider extends $FunctionalProvider<User?, User?, User?>
|
||||
with $Provider<User?> {
|
||||
/// Convenience provider for getting current user
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final user = ref.watch(currentUserProvider);
|
||||
/// if (user != null) {
|
||||
/// Text('Welcome ${user.fullName}');
|
||||
/// }
|
||||
/// ```
|
||||
const CurrentUserProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'currentUserProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$currentUserHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<User?> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
User? create(Ref ref) {
|
||||
return currentUser(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(User? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<User?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$currentUserHash() => r'f3c1da551f4a4c2bf158782ea37a4749a718128a';
|
||||
|
||||
/// Convenience provider for user's loyalty tier
|
||||
///
|
||||
/// Returns the current user's loyalty tier or null if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final tier = ref.watch(userLoyaltyTierProvider);
|
||||
/// if (tier != null) {
|
||||
/// Text('Tier: ${tier.displayName}');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(userLoyaltyTier)
|
||||
const userLoyaltyTierProvider = UserLoyaltyTierProvider._();
|
||||
|
||||
/// Convenience provider for user's loyalty tier
|
||||
///
|
||||
/// Returns the current user's loyalty tier or null if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final tier = ref.watch(userLoyaltyTierProvider);
|
||||
/// if (tier != null) {
|
||||
/// Text('Tier: ${tier.displayName}');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class UserLoyaltyTierProvider
|
||||
extends $FunctionalProvider<LoyaltyTier?, LoyaltyTier?, LoyaltyTier?>
|
||||
with $Provider<LoyaltyTier?> {
|
||||
/// Convenience provider for user's loyalty tier
|
||||
///
|
||||
/// Returns the current user's loyalty tier or null if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final tier = ref.watch(userLoyaltyTierProvider);
|
||||
/// if (tier != null) {
|
||||
/// Text('Tier: ${tier.displayName}');
|
||||
/// }
|
||||
/// ```
|
||||
const UserLoyaltyTierProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'userLoyaltyTierProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$userLoyaltyTierHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<LoyaltyTier?> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
LoyaltyTier? create(Ref ref) {
|
||||
return userLoyaltyTier(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(LoyaltyTier? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<LoyaltyTier?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$userLoyaltyTierHash() => r'f1a157486b8bdd2cf64bc2201207f2ac71ea6a69';
|
||||
|
||||
/// Convenience provider for user's total points
|
||||
///
|
||||
/// Returns the current user's total loyalty points or 0 if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final points = ref.watch(userTotalPointsProvider);
|
||||
/// Text('Points: $points');
|
||||
/// ```
|
||||
|
||||
@ProviderFor(userTotalPoints)
|
||||
const userTotalPointsProvider = UserTotalPointsProvider._();
|
||||
|
||||
/// Convenience provider for user's total points
|
||||
///
|
||||
/// Returns the current user's total loyalty points or 0 if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final points = ref.watch(userTotalPointsProvider);
|
||||
/// Text('Points: $points');
|
||||
/// ```
|
||||
|
||||
final class UserTotalPointsProvider extends $FunctionalProvider<int, int, int>
|
||||
with $Provider<int> {
|
||||
/// Convenience provider for user's total points
|
||||
///
|
||||
/// Returns the current user's total loyalty points or 0 if not logged in.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final points = ref.watch(userTotalPointsProvider);
|
||||
/// Text('Points: $points');
|
||||
/// ```
|
||||
const UserTotalPointsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'userTotalPointsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$userTotalPointsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
int create(Ref ref) {
|
||||
return userTotalPoints(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(int value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<int>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$userTotalPointsHash() => r'9ccebb48a8641c3c0624b1649303b436e82602bd';
|
||||
@@ -0,0 +1,112 @@
|
||||
/// Password Visibility Provider
|
||||
///
|
||||
/// Simple state provider for toggling password visibility in login/register forms.
|
||||
///
|
||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'password_visibility_provider.g.dart';
|
||||
|
||||
/// Password Visibility State Provider
|
||||
///
|
||||
/// Manages the visibility state of password input fields.
|
||||
/// Default state is false (password hidden).
|
||||
///
|
||||
/// Usage in login/register pages:
|
||||
/// ```dart
|
||||
/// class LoginPage extends ConsumerWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
///
|
||||
/// return TextField(
|
||||
/// obscureText: !isPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
class PasswordVisibility extends _$PasswordVisibility {
|
||||
/// Initialize with password hidden (false)
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
/// Toggle password visibility
|
||||
///
|
||||
/// Switches between showing and hiding the password.
|
||||
void toggle() {
|
||||
state = !state;
|
||||
}
|
||||
|
||||
/// Show password
|
||||
///
|
||||
/// Sets visibility to true (password visible).
|
||||
void show() {
|
||||
state = true;
|
||||
}
|
||||
|
||||
/// Hide password
|
||||
///
|
||||
/// Sets visibility to false (password hidden).
|
||||
void hide() {
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm Password Visibility State Provider
|
||||
///
|
||||
/// Separate provider for confirm password field in registration forms.
|
||||
/// This allows independent control of password and confirm password visibility.
|
||||
///
|
||||
/// Usage in registration page:
|
||||
/// ```dart
|
||||
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
|
||||
///
|
||||
/// TextField(
|
||||
/// obscureText: !isConfirmPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// labelText: 'Xác nhận mật khẩu',
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
@riverpod
|
||||
class ConfirmPasswordVisibility extends _$ConfirmPasswordVisibility {
|
||||
/// Initialize with password hidden (false)
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
/// Toggle confirm password visibility
|
||||
void toggle() {
|
||||
state = !state;
|
||||
}
|
||||
|
||||
/// Show confirm password
|
||||
void show() {
|
||||
state = true;
|
||||
}
|
||||
|
||||
/// Hide confirm password
|
||||
void hide() {
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'password_visibility_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Password Visibility State Provider
|
||||
///
|
||||
/// Manages the visibility state of password input fields.
|
||||
/// Default state is false (password hidden).
|
||||
///
|
||||
/// Usage in login/register pages:
|
||||
/// ```dart
|
||||
/// class LoginPage extends ConsumerWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
///
|
||||
/// return TextField(
|
||||
/// obscureText: !isPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(PasswordVisibility)
|
||||
const passwordVisibilityProvider = PasswordVisibilityProvider._();
|
||||
|
||||
/// Password Visibility State Provider
|
||||
///
|
||||
/// Manages the visibility state of password input fields.
|
||||
/// Default state is false (password hidden).
|
||||
///
|
||||
/// Usage in login/register pages:
|
||||
/// ```dart
|
||||
/// class LoginPage extends ConsumerWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
///
|
||||
/// return TextField(
|
||||
/// obscureText: !isPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
final class PasswordVisibilityProvider
|
||||
extends $NotifierProvider<PasswordVisibility, bool> {
|
||||
/// Password Visibility State Provider
|
||||
///
|
||||
/// Manages the visibility state of password input fields.
|
||||
/// Default state is false (password hidden).
|
||||
///
|
||||
/// Usage in login/register pages:
|
||||
/// ```dart
|
||||
/// class LoginPage extends ConsumerWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
///
|
||||
/// return TextField(
|
||||
/// obscureText: !isPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
const PasswordVisibilityProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'passwordVisibilityProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$passwordVisibilityHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
PasswordVisibility create() => PasswordVisibility();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$passwordVisibilityHash() =>
|
||||
r'25b6fa914e42dd83c8443aecbeb1d608cccd00ab';
|
||||
|
||||
/// Password Visibility State Provider
|
||||
///
|
||||
/// Manages the visibility state of password input fields.
|
||||
/// Default state is false (password hidden).
|
||||
///
|
||||
/// Usage in login/register pages:
|
||||
/// ```dart
|
||||
/// class LoginPage extends ConsumerWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context, WidgetRef ref) {
|
||||
/// final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||
///
|
||||
/// return TextField(
|
||||
/// obscureText: !isPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(passwordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
abstract class _$PasswordVisibility extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm Password Visibility State Provider
|
||||
///
|
||||
/// Separate provider for confirm password field in registration forms.
|
||||
/// This allows independent control of password and confirm password visibility.
|
||||
///
|
||||
/// Usage in registration page:
|
||||
/// ```dart
|
||||
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
|
||||
///
|
||||
/// TextField(
|
||||
/// obscureText: !isConfirmPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// labelText: 'Xác nhận mật khẩu',
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(ConfirmPasswordVisibility)
|
||||
const confirmPasswordVisibilityProvider = ConfirmPasswordVisibilityProvider._();
|
||||
|
||||
/// Confirm Password Visibility State Provider
|
||||
///
|
||||
/// Separate provider for confirm password field in registration forms.
|
||||
/// This allows independent control of password and confirm password visibility.
|
||||
///
|
||||
/// Usage in registration page:
|
||||
/// ```dart
|
||||
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
|
||||
///
|
||||
/// TextField(
|
||||
/// obscureText: !isConfirmPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// labelText: 'Xác nhận mật khẩu',
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
final class ConfirmPasswordVisibilityProvider
|
||||
extends $NotifierProvider<ConfirmPasswordVisibility, bool> {
|
||||
/// Confirm Password Visibility State Provider
|
||||
///
|
||||
/// Separate provider for confirm password field in registration forms.
|
||||
/// This allows independent control of password and confirm password visibility.
|
||||
///
|
||||
/// Usage in registration page:
|
||||
/// ```dart
|
||||
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
|
||||
///
|
||||
/// TextField(
|
||||
/// obscureText: !isConfirmPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// labelText: 'Xác nhận mật khẩu',
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
const ConfirmPasswordVisibilityProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'confirmPasswordVisibilityProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$confirmPasswordVisibilityHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ConfirmPasswordVisibility create() => ConfirmPasswordVisibility();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$confirmPasswordVisibilityHash() =>
|
||||
r'8408bba9db1e8deba425f98015a4e2fa76d75eb8';
|
||||
|
||||
/// Confirm Password Visibility State Provider
|
||||
///
|
||||
/// Separate provider for confirm password field in registration forms.
|
||||
/// This allows independent control of password and confirm password visibility.
|
||||
///
|
||||
/// Usage in registration page:
|
||||
/// ```dart
|
||||
/// final isConfirmPasswordVisible = ref.watch(confirmPasswordVisibilityProvider);
|
||||
///
|
||||
/// TextField(
|
||||
/// obscureText: !isConfirmPasswordVisible,
|
||||
/// decoration: InputDecoration(
|
||||
/// labelText: 'Xác nhận mật khẩu',
|
||||
/// suffixIcon: IconButton(
|
||||
/// icon: Icon(
|
||||
/// isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
|
||||
/// ),
|
||||
/// onPressed: () {
|
||||
/// ref.read(confirmPasswordVisibilityProvider.notifier).toggle();
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
abstract class _$ConfirmPasswordVisibility extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
305
lib/features/auth/presentation/providers/register_provider.dart
Normal file
305
lib/features/auth/presentation/providers/register_provider.dart
Normal file
@@ -0,0 +1,305 @@
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Manages registration state for the Worker application.
|
||||
/// Handles user registration with role-based validation and verification.
|
||||
///
|
||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/auth/domain/entities/user.dart';
|
||||
|
||||
part 'register_provider.g.dart';
|
||||
|
||||
/// Registration Form Data
|
||||
///
|
||||
/// Contains all data needed for user registration.
|
||||
/// Optional fields are used based on selected role.
|
||||
class RegistrationData {
|
||||
/// Required: Full name of the user
|
||||
final String fullName;
|
||||
|
||||
/// Required: Phone number (Vietnamese format)
|
||||
final String phoneNumber;
|
||||
|
||||
/// Required: Email address
|
||||
final String email;
|
||||
|
||||
/// Required: Password (minimum 6 characters)
|
||||
final String password;
|
||||
|
||||
/// Required: User role
|
||||
final UserRole role;
|
||||
|
||||
/// Optional: CCCD/ID card number (required for dealer/worker roles)
|
||||
final String? cccd;
|
||||
|
||||
/// Optional: Tax code (personal or company)
|
||||
final String? taxCode;
|
||||
|
||||
/// Optional: Company/store name
|
||||
final String? companyName;
|
||||
|
||||
/// Required: Province/city
|
||||
final String? city;
|
||||
|
||||
/// Optional: Attachment file paths (ID card, certificate, license)
|
||||
final List<String>? attachments;
|
||||
|
||||
const RegistrationData({
|
||||
required this.fullName,
|
||||
required this.phoneNumber,
|
||||
required this.email,
|
||||
required this.password,
|
||||
required this.role,
|
||||
this.cccd,
|
||||
this.taxCode,
|
||||
this.companyName,
|
||||
this.city,
|
||||
this.attachments,
|
||||
});
|
||||
|
||||
/// Copy with method for immutability
|
||||
RegistrationData copyWith({
|
||||
String? fullName,
|
||||
String? phoneNumber,
|
||||
String? email,
|
||||
String? password,
|
||||
UserRole? role,
|
||||
String? cccd,
|
||||
String? taxCode,
|
||||
String? companyName,
|
||||
String? city,
|
||||
List<String>? attachments,
|
||||
}) {
|
||||
return RegistrationData(
|
||||
fullName: fullName ?? this.fullName,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
email: email ?? this.email,
|
||||
password: password ?? this.password,
|
||||
role: role ?? this.role,
|
||||
cccd: cccd ?? this.cccd,
|
||||
taxCode: taxCode ?? this.taxCode,
|
||||
companyName: companyName ?? this.companyName,
|
||||
city: city ?? this.city,
|
||||
attachments: attachments ?? this.attachments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Main provider for user registration state management.
|
||||
/// Handles registration process with role-based validation.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final registerState = ref.watch(registerProvider);
|
||||
/// registerState.when(
|
||||
/// data: (user) => SuccessScreen(user),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
@riverpod
|
||||
class Register extends _$Register {
|
||||
/// Initialize with no registration result
|
||||
@override
|
||||
Future<User?> build() async {
|
||||
// No initial registration
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Register a new user
|
||||
///
|
||||
/// Performs user registration with role-based validation.
|
||||
/// For dealer/worker roles, requires additional verification documents.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [data]: Registration form data containing all required fields
|
||||
///
|
||||
/// Returns: Newly created User object on success
|
||||
///
|
||||
/// Throws: Exception on validation failure or registration error
|
||||
///
|
||||
/// Error messages (Vietnamese):
|
||||
/// - "Vui lòng điền đầy đủ thông tin bắt buộc"
|
||||
/// - "Số điện thoại không hợp lệ"
|
||||
/// - "Email không hợp lệ"
|
||||
/// - "Mật khẩu phải có ít nhất 6 ký tự"
|
||||
/// - "Vui lòng nhập số CCCD/CMND" (for dealer/worker)
|
||||
/// - "Vui lòng tải lên ảnh CCCD/CMND" (for dealer/worker)
|
||||
/// - "Vui lòng tải lên ảnh chứng chỉ hành nghề hoặc GPKD" (for dealer/worker)
|
||||
/// - "Số điện thoại đã được đăng ký"
|
||||
/// - "Email đã được đăng ký"
|
||||
Future<void> register(RegistrationData data) async {
|
||||
// Set loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
// Perform registration with error handling
|
||||
state = await AsyncValue.guard(() async {
|
||||
// Validate required fields
|
||||
if (data.fullName.isEmpty ||
|
||||
data.phoneNumber.isEmpty ||
|
||||
data.email.isEmpty ||
|
||||
data.password.isEmpty ||
|
||||
data.city == null ||
|
||||
data.city!.isEmpty) {
|
||||
throw Exception('Vui lòng điền đầy đủ thông tin bắt buộc');
|
||||
}
|
||||
|
||||
// Validate phone number (Vietnamese format: 10 digits starting with 0)
|
||||
final phoneRegex = RegExp(r'^0[0-9]{9}$');
|
||||
if (!phoneRegex.hasMatch(data.phoneNumber)) {
|
||||
throw Exception('Số điện thoại không hợp lệ');
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||
if (!emailRegex.hasMatch(data.email)) {
|
||||
throw Exception('Email không hợp lệ');
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (data.password.length < 6) {
|
||||
throw Exception('Mật khẩu phải có ít nhất 6 ký tự');
|
||||
}
|
||||
|
||||
// Role-based validation for dealer/worker (requires verification)
|
||||
if (data.role == UserRole.customer) {
|
||||
// For dealer/worker roles, CCCD and attachments are required
|
||||
if (data.cccd == null || data.cccd!.isEmpty) {
|
||||
throw Exception('Vui lòng nhập số CCCD/CMND');
|
||||
}
|
||||
|
||||
// Validate CCCD format (9 or 12 digits)
|
||||
final cccdRegex = RegExp(r'^[0-9]{9}$|^[0-9]{12}$');
|
||||
if (!cccdRegex.hasMatch(data.cccd!)) {
|
||||
throw Exception('Số CCCD/CMND không hợp lệ (phải có 9 hoặc 12 số)');
|
||||
}
|
||||
|
||||
// Validate attachments
|
||||
if (data.attachments == null || data.attachments!.isEmpty) {
|
||||
throw Exception('Vui lòng tải lên ảnh CCCD/CMND');
|
||||
}
|
||||
|
||||
if (data.attachments!.length < 2) {
|
||||
throw Exception('Vui lòng tải lên ảnh chứng chỉ hành nghề hoặc GPKD');
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate API call delay (2 seconds)
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
|
||||
// TODO: In production, call the registration API here
|
||||
// final response = await ref.read(authRepositoryProvider).register(data);
|
||||
|
||||
// Mock: Simulate registration success
|
||||
final now = DateTime.now();
|
||||
|
||||
// Determine initial status based on role
|
||||
// Dealer/Worker require admin approval (pending status)
|
||||
// Other roles are immediately active
|
||||
final initialStatus = data.role == UserRole.customer
|
||||
? UserStatus.pending
|
||||
: UserStatus.active;
|
||||
|
||||
// Create new user entity
|
||||
final newUser = User(
|
||||
userId: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
phoneNumber: data.phoneNumber,
|
||||
fullName: data.fullName,
|
||||
email: data.email,
|
||||
role: data.role,
|
||||
status: initialStatus,
|
||||
loyaltyTier: LoyaltyTier.gold, // Default tier for new users
|
||||
totalPoints: 0, // New users start with 0 points
|
||||
companyInfo: data.companyName != null || data.taxCode != null
|
||||
? CompanyInfo(
|
||||
name: data.companyName,
|
||||
taxId: data.taxCode,
|
||||
businessType: _getBusinessType(data.role),
|
||||
)
|
||||
: null,
|
||||
cccd: data.cccd,
|
||||
attachments: data.attachments ?? [],
|
||||
address: data.city,
|
||||
avatarUrl: null,
|
||||
referralCode: 'REF${data.phoneNumber.substring(0, 6)}',
|
||||
referredBy: null,
|
||||
erpnextCustomerId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoginAt: null, // Not logged in yet
|
||||
);
|
||||
|
||||
return newUser;
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset registration state
|
||||
///
|
||||
/// Clears the registration result. Useful when navigating away
|
||||
/// from success screen or starting a new registration.
|
||||
Future<void> reset() async {
|
||||
state = const AsyncValue.data(null);
|
||||
}
|
||||
|
||||
/// Get business type based on user role
|
||||
String _getBusinessType(UserRole role) {
|
||||
switch (role) {
|
||||
case UserRole.customer:
|
||||
return 'Đại lý/Thầu thợ/Kiến trúc sư';
|
||||
case UserRole.sales:
|
||||
return 'Nhân viên kinh doanh';
|
||||
case UserRole.admin:
|
||||
return 'Quản trị viên';
|
||||
case UserRole.accountant:
|
||||
return 'Kế toán';
|
||||
case UserRole.designer:
|
||||
return 'Thiết kế';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if registration is in progress
|
||||
bool get isLoading => state.isLoading;
|
||||
|
||||
/// Get registration error if any
|
||||
Object? get error => state.error;
|
||||
|
||||
/// Get registered user if successful
|
||||
User? get registeredUser => state.value;
|
||||
|
||||
/// Check if registration was successful
|
||||
bool get isSuccess => state.hasValue && state.value != null;
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if registration is in progress
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isRegistering = ref.watch(isRegisteringProvider);
|
||||
/// if (isRegistering) {
|
||||
/// // Show loading indicator
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
bool isRegistering(Ref ref) {
|
||||
final registerState = ref.watch(registerProvider);
|
||||
return registerState.isLoading;
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if registration was successful
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final success = ref.watch(registrationSuccessProvider);
|
||||
/// if (success) {
|
||||
/// // Navigate to pending approval or OTP screen
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
bool registrationSuccess(Ref ref) {
|
||||
final registerState = ref.watch(registerProvider);
|
||||
return registerState.hasValue && registerState.value != null;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'register_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Main provider for user registration state management.
|
||||
/// Handles registration process with role-based validation.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final registerState = ref.watch(registerProvider);
|
||||
/// registerState.when(
|
||||
/// data: (user) => SuccessScreen(user),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(Register)
|
||||
const registerProvider = RegisterProvider._();
|
||||
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Main provider for user registration state management.
|
||||
/// Handles registration process with role-based validation.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final registerState = ref.watch(registerProvider);
|
||||
/// registerState.when(
|
||||
/// data: (user) => SuccessScreen(user),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
final class RegisterProvider extends $AsyncNotifierProvider<Register, User?> {
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Main provider for user registration state management.
|
||||
/// Handles registration process with role-based validation.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final registerState = ref.watch(registerProvider);
|
||||
/// registerState.when(
|
||||
/// data: (user) => SuccessScreen(user),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
const RegisterProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'registerProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$registerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
Register create() => Register();
|
||||
}
|
||||
|
||||
String _$registerHash() => r'a073b5c5958b74c63a3cddfec7f6f018e14a5088';
|
||||
|
||||
/// Registration State Provider
|
||||
///
|
||||
/// Main provider for user registration state management.
|
||||
/// Handles registration process with role-based validation.
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// final registerState = ref.watch(registerProvider);
|
||||
/// registerState.when(
|
||||
/// data: (user) => SuccessScreen(user),
|
||||
/// loading: () => LoadingIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
abstract class _$Register extends $AsyncNotifier<User?> {
|
||||
FutureOr<User?> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<AsyncValue<User?>, User?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<User?>, User?>,
|
||||
AsyncValue<User?>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if registration is in progress
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isRegistering = ref.watch(isRegisteringProvider);
|
||||
/// if (isRegistering) {
|
||||
/// // Show loading indicator
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(isRegistering)
|
||||
const isRegisteringProvider = IsRegisteringProvider._();
|
||||
|
||||
/// Convenience provider for checking if registration is in progress
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isRegistering = ref.watch(isRegisteringProvider);
|
||||
/// if (isRegistering) {
|
||||
/// // Show loading indicator
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class IsRegisteringProvider extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Convenience provider for checking if registration is in progress
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final isRegistering = ref.watch(isRegisteringProvider);
|
||||
/// if (isRegistering) {
|
||||
/// // Show loading indicator
|
||||
/// }
|
||||
/// ```
|
||||
const IsRegisteringProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'isRegisteringProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$isRegisteringHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return isRegistering(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$isRegisteringHash() => r'2108b87b37451de9aaf799f9b8b380924bed2c87';
|
||||
|
||||
/// Convenience provider for checking if registration was successful
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final success = ref.watch(registrationSuccessProvider);
|
||||
/// if (success) {
|
||||
/// // Navigate to pending approval or OTP screen
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(registrationSuccess)
|
||||
const registrationSuccessProvider = RegistrationSuccessProvider._();
|
||||
|
||||
/// Convenience provider for checking if registration was successful
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final success = ref.watch(registrationSuccessProvider);
|
||||
/// if (success) {
|
||||
/// // Navigate to pending approval or OTP screen
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class RegistrationSuccessProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Convenience provider for checking if registration was successful
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final success = ref.watch(registrationSuccessProvider);
|
||||
/// if (success) {
|
||||
/// // Navigate to pending approval or OTP screen
|
||||
/// }
|
||||
/// ```
|
||||
const RegistrationSuccessProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'registrationSuccessProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$registrationSuccessHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return registrationSuccess(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$registrationSuccessHash() =>
|
||||
r'6435b9ca4bf4c287497a39077a5d4558e0515ddc';
|
||||
@@ -0,0 +1,175 @@
|
||||
/// Selected Role State Provider
|
||||
///
|
||||
/// Manages the selected user role during registration.
|
||||
/// Simple state provider for role selection in the registration form.
|
||||
///
|
||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/auth/domain/entities/user.dart';
|
||||
|
||||
part 'selected_role_provider.g.dart';
|
||||
|
||||
/// Selected Role Provider
|
||||
///
|
||||
/// Manages the currently selected user role in the registration form.
|
||||
/// Provides methods to select and clear role selection.
|
||||
///
|
||||
/// This provider is used to:
|
||||
/// - Track which role the user has selected
|
||||
/// - Conditionally show/hide verification fields based on role
|
||||
/// - Validate required documents for dealer/worker roles
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// // Watch the selected role
|
||||
/// final selectedRole = ref.watch(selectedRoleProvider);
|
||||
///
|
||||
/// // Select a role
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
///
|
||||
/// // Clear selection
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
///
|
||||
/// // Show verification section conditionally
|
||||
/// if (selectedRole == UserRole.customer) {
|
||||
/// VerificationSection(),
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
class SelectedRole extends _$SelectedRole {
|
||||
/// Initialize with no role selected
|
||||
@override
|
||||
UserRole? build() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Select a user role
|
||||
///
|
||||
/// Updates the state with the newly selected role.
|
||||
/// This triggers UI updates that depend on role selection.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [role]: The user role to select
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// // User selects "Đại lý hệ thống" (dealer)
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
/// // This will show verification fields
|
||||
/// ```
|
||||
void selectRole(UserRole role) {
|
||||
state = role;
|
||||
}
|
||||
|
||||
/// Clear the role selection
|
||||
///
|
||||
/// Resets the state to null (no role selected).
|
||||
/// Useful when resetting the form or canceling registration.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// // User clicks "Cancel" or goes back
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
/// // This will hide verification fields
|
||||
/// ```
|
||||
void clearRole() {
|
||||
state = null;
|
||||
}
|
||||
|
||||
/// Check if a role is currently selected
|
||||
///
|
||||
/// Returns true if any role has been selected, false otherwise.
|
||||
bool get hasSelection => state != null;
|
||||
|
||||
/// Check if verification is required for current role
|
||||
///
|
||||
/// Returns true if the selected role requires verification documents
|
||||
/// (CCCD, certificates, etc.). Currently only customer role requires this.
|
||||
///
|
||||
/// This is used to conditionally show the verification section:
|
||||
/// ```dart
|
||||
/// if (ref.read(selectedRoleProvider.notifier).requiresVerification) {
|
||||
/// // Show CCCD input, file uploads, etc.
|
||||
/// }
|
||||
/// ```
|
||||
bool get requiresVerification => state == UserRole.customer;
|
||||
|
||||
/// Get the display name for the current role (Vietnamese)
|
||||
///
|
||||
/// Returns a user-friendly Vietnamese name for the selected role,
|
||||
/// or null if no role is selected.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// final displayName = ref.read(selectedRoleProvider.notifier).displayName;
|
||||
/// // Returns: "Đại lý hệ thống" for customer role
|
||||
/// ```
|
||||
String? get displayName {
|
||||
if (state == null) return null;
|
||||
|
||||
switch (state!) {
|
||||
case UserRole.customer:
|
||||
return 'Đại lý/Thầu thợ/Kiến trúc sư';
|
||||
case UserRole.sales:
|
||||
return 'Nhân viên kinh doanh';
|
||||
case UserRole.admin:
|
||||
return 'Quản trị viên';
|
||||
case UserRole.accountant:
|
||||
return 'Kế toán';
|
||||
case UserRole.designer:
|
||||
return 'Thiết kế';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if verification is required
|
||||
///
|
||||
/// Returns true if the currently selected role requires verification
|
||||
/// documents (CCCD, certificates, etc.).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final needsVerification = ref.watch(requiresVerificationProvider);
|
||||
/// if (needsVerification) {
|
||||
/// // Show verification section with file uploads
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
bool requiresVerification(Ref ref) {
|
||||
final selectedRole = ref.watch(selectedRoleProvider);
|
||||
return selectedRole == UserRole.customer;
|
||||
}
|
||||
|
||||
/// Convenience provider for getting role display name
|
||||
///
|
||||
/// Returns a user-friendly Vietnamese name for the selected role,
|
||||
/// or null if no role is selected.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final roleName = ref.watch(roleDisplayNameProvider);
|
||||
/// if (roleName != null) {
|
||||
/// Text('Bạn đang đăng ký với vai trò: $roleName');
|
||||
/// }
|
||||
/// ```
|
||||
@riverpod
|
||||
String? roleDisplayName(Ref ref) {
|
||||
final selectedRole = ref.watch(selectedRoleProvider);
|
||||
|
||||
if (selectedRole == null) return null;
|
||||
|
||||
switch (selectedRole) {
|
||||
case UserRole.customer:
|
||||
return 'Đại lý/Thầu thợ/Kiến trúc sư';
|
||||
case UserRole.sales:
|
||||
return 'Nhân viên kinh doanh';
|
||||
case UserRole.admin:
|
||||
return 'Quản trị viên';
|
||||
case UserRole.accountant:
|
||||
return 'Kế toán';
|
||||
case UserRole.designer:
|
||||
return 'Thiết kế';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'selected_role_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Selected Role Provider
|
||||
///
|
||||
/// Manages the currently selected user role in the registration form.
|
||||
/// Provides methods to select and clear role selection.
|
||||
///
|
||||
/// This provider is used to:
|
||||
/// - Track which role the user has selected
|
||||
/// - Conditionally show/hide verification fields based on role
|
||||
/// - Validate required documents for dealer/worker roles
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// // Watch the selected role
|
||||
/// final selectedRole = ref.watch(selectedRoleProvider);
|
||||
///
|
||||
/// // Select a role
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
///
|
||||
/// // Clear selection
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
///
|
||||
/// // Show verification section conditionally
|
||||
/// if (selectedRole == UserRole.customer) {
|
||||
/// VerificationSection(),
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(SelectedRole)
|
||||
const selectedRoleProvider = SelectedRoleProvider._();
|
||||
|
||||
/// Selected Role Provider
|
||||
///
|
||||
/// Manages the currently selected user role in the registration form.
|
||||
/// Provides methods to select and clear role selection.
|
||||
///
|
||||
/// This provider is used to:
|
||||
/// - Track which role the user has selected
|
||||
/// - Conditionally show/hide verification fields based on role
|
||||
/// - Validate required documents for dealer/worker roles
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// // Watch the selected role
|
||||
/// final selectedRole = ref.watch(selectedRoleProvider);
|
||||
///
|
||||
/// // Select a role
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
///
|
||||
/// // Clear selection
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
///
|
||||
/// // Show verification section conditionally
|
||||
/// if (selectedRole == UserRole.customer) {
|
||||
/// VerificationSection(),
|
||||
/// }
|
||||
/// ```
|
||||
final class SelectedRoleProvider
|
||||
extends $NotifierProvider<SelectedRole, UserRole?> {
|
||||
/// Selected Role Provider
|
||||
///
|
||||
/// Manages the currently selected user role in the registration form.
|
||||
/// Provides methods to select and clear role selection.
|
||||
///
|
||||
/// This provider is used to:
|
||||
/// - Track which role the user has selected
|
||||
/// - Conditionally show/hide verification fields based on role
|
||||
/// - Validate required documents for dealer/worker roles
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// // Watch the selected role
|
||||
/// final selectedRole = ref.watch(selectedRoleProvider);
|
||||
///
|
||||
/// // Select a role
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
///
|
||||
/// // Clear selection
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
///
|
||||
/// // Show verification section conditionally
|
||||
/// if (selectedRole == UserRole.customer) {
|
||||
/// VerificationSection(),
|
||||
/// }
|
||||
/// ```
|
||||
const SelectedRoleProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'selectedRoleProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$selectedRoleHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SelectedRole create() => SelectedRole();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(UserRole? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<UserRole?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedRoleHash() => r'098c7fdaec4694d14a48c049556960eb6ed2dc06';
|
||||
|
||||
/// Selected Role Provider
|
||||
///
|
||||
/// Manages the currently selected user role in the registration form.
|
||||
/// Provides methods to select and clear role selection.
|
||||
///
|
||||
/// This provider is used to:
|
||||
/// - Track which role the user has selected
|
||||
/// - Conditionally show/hide verification fields based on role
|
||||
/// - Validate required documents for dealer/worker roles
|
||||
///
|
||||
/// Usage in widgets:
|
||||
/// ```dart
|
||||
/// // Watch the selected role
|
||||
/// final selectedRole = ref.watch(selectedRoleProvider);
|
||||
///
|
||||
/// // Select a role
|
||||
/// ref.read(selectedRoleProvider.notifier).selectRole(UserRole.customer);
|
||||
///
|
||||
/// // Clear selection
|
||||
/// ref.read(selectedRoleProvider.notifier).clearRole();
|
||||
///
|
||||
/// // Show verification section conditionally
|
||||
/// if (selectedRole == UserRole.customer) {
|
||||
/// VerificationSection(),
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
abstract class _$SelectedRole extends $Notifier<UserRole?> {
|
||||
UserRole? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<UserRole?, UserRole?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<UserRole?, UserRole?>,
|
||||
UserRole?,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience provider for checking if verification is required
|
||||
///
|
||||
/// Returns true if the currently selected role requires verification
|
||||
/// documents (CCCD, certificates, etc.).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final needsVerification = ref.watch(requiresVerificationProvider);
|
||||
/// if (needsVerification) {
|
||||
/// // Show verification section with file uploads
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(requiresVerification)
|
||||
const requiresVerificationProvider = RequiresVerificationProvider._();
|
||||
|
||||
/// Convenience provider for checking if verification is required
|
||||
///
|
||||
/// Returns true if the currently selected role requires verification
|
||||
/// documents (CCCD, certificates, etc.).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final needsVerification = ref.watch(requiresVerificationProvider);
|
||||
/// if (needsVerification) {
|
||||
/// // Show verification section with file uploads
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class RequiresVerificationProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Convenience provider for checking if verification is required
|
||||
///
|
||||
/// Returns true if the currently selected role requires verification
|
||||
/// documents (CCCD, certificates, etc.).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final needsVerification = ref.watch(requiresVerificationProvider);
|
||||
/// if (needsVerification) {
|
||||
/// // Show verification section with file uploads
|
||||
/// }
|
||||
/// ```
|
||||
const RequiresVerificationProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'requiresVerificationProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$requiresVerificationHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return requiresVerification(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$requiresVerificationHash() =>
|
||||
r'400b4242bca2defd14e46361d2b77dd94a4e3e5e';
|
||||
|
||||
/// Convenience provider for getting role display name
|
||||
///
|
||||
/// Returns a user-friendly Vietnamese name for the selected role,
|
||||
/// or null if no role is selected.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final roleName = ref.watch(roleDisplayNameProvider);
|
||||
/// if (roleName != null) {
|
||||
/// Text('Bạn đang đăng ký với vai trò: $roleName');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@ProviderFor(roleDisplayName)
|
||||
const roleDisplayNameProvider = RoleDisplayNameProvider._();
|
||||
|
||||
/// Convenience provider for getting role display name
|
||||
///
|
||||
/// Returns a user-friendly Vietnamese name for the selected role,
|
||||
/// or null if no role is selected.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final roleName = ref.watch(roleDisplayNameProvider);
|
||||
/// if (roleName != null) {
|
||||
/// Text('Bạn đang đăng ký với vai trò: $roleName');
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
final class RoleDisplayNameProvider
|
||||
extends $FunctionalProvider<String?, String?, String?>
|
||||
with $Provider<String?> {
|
||||
/// Convenience provider for getting role display name
|
||||
///
|
||||
/// Returns a user-friendly Vietnamese name for the selected role,
|
||||
/// or null if no role is selected.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final roleName = ref.watch(roleDisplayNameProvider);
|
||||
/// if (roleName != null) {
|
||||
/// Text('Bạn đang đăng ký với vai trò: $roleName');
|
||||
/// }
|
||||
/// ```
|
||||
const RoleDisplayNameProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'roleDisplayNameProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$roleDisplayNameHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<String?> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
String? create(Ref ref) {
|
||||
return roleDisplayName(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$roleDisplayNameHash() => r'6cb4bfd9e76fb2f3ed52d4a249e5a2477bc6f39e';
|
||||
216
lib/features/auth/presentation/widgets/file_upload_card.dart
Normal file
216
lib/features/auth/presentation/widgets/file_upload_card.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
/// File Upload Card Widget
|
||||
///
|
||||
/// Reusable widget for uploading image files with preview.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
|
||||
/// File Upload Card
|
||||
///
|
||||
/// A reusable widget for uploading files with preview functionality.
|
||||
/// Features:
|
||||
/// - Dashed border upload area
|
||||
/// - Camera/file icon
|
||||
/// - Title and subtitle
|
||||
/// - Image preview after selection
|
||||
/// - Remove button
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// FileUploadCard(
|
||||
/// file: selectedFile,
|
||||
/// onTap: () => pickImage(),
|
||||
/// onRemove: () => removeImage(),
|
||||
/// icon: Icons.camera_alt,
|
||||
/// title: 'Chụp ảnh hoặc chọn file',
|
||||
/// subtitle: 'JPG, PNG tối đa 5MB',
|
||||
/// )
|
||||
/// ```
|
||||
class FileUploadCard extends StatelessWidget {
|
||||
/// Creates a file upload card
|
||||
const FileUploadCard({
|
||||
super.key,
|
||||
required this.file,
|
||||
required this.onTap,
|
||||
required this.onRemove,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
|
||||
/// Selected file (null if not selected)
|
||||
final File? file;
|
||||
|
||||
/// Callback when upload area is tapped
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Callback to remove selected file
|
||||
final VoidCallback onRemove;
|
||||
|
||||
/// Icon to display in upload area
|
||||
final IconData icon;
|
||||
|
||||
/// Title text
|
||||
final String title;
|
||||
|
||||
/// Subtitle text
|
||||
final String subtitle;
|
||||
|
||||
/// Format file size in bytes to human-readable string
|
||||
String _formatFileSize(int bytes) {
|
||||
if (bytes == 0) return '0 B';
|
||||
const suffixes = ['B', 'KB', 'MB', 'GB'];
|
||||
final i = (bytes.bitLength - 1) ~/ 10;
|
||||
final size = bytes / (1 << (i * 10));
|
||||
return '${size.toStringAsFixed(2)} ${suffixes[i]}';
|
||||
}
|
||||
|
||||
/// Get file name from path
|
||||
String _getFileName(String path) {
|
||||
return path.split('/').last;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (file != null) {
|
||||
// Show preview with remove option
|
||||
return _buildPreview(context);
|
||||
} else {
|
||||
// Show upload area
|
||||
return _buildUploadArea(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build upload area
|
||||
Widget _buildUploadArea(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
border: Border.all(
|
||||
color: const Color(0xFFCBD5E1),
|
||||
width: 2,
|
||||
strokeAlign: BorderSide.strokeAlignInside,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
children: [
|
||||
// Icon
|
||||
Icon(icon, size: 32, color: AppColors.grey500),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// Title
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
// Subtitle
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build preview with remove button
|
||||
Widget _buildPreview(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
border: Border.all(color: AppColors.grey100, width: 1),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
// Thumbnail
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: Image.file(
|
||||
file!,
|
||||
width: 50,
|
||||
height: 50,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
color: AppColors.grey100,
|
||||
child: const Icon(
|
||||
Icons.broken_image,
|
||||
color: AppColors.grey500,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
|
||||
// File info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_getFileName(file!.path),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
FutureBuilder<int>(
|
||||
future: file!.length(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
_formatFileSize(snapshot.data!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppColors.danger, size: 20),
|
||||
onPressed: onRemove,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
splashRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
lib/features/auth/presentation/widgets/phone_input_field.dart
Normal file
133
lib/features/auth/presentation/widgets/phone_input_field.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
/// Phone Input Field Widget
|
||||
///
|
||||
/// Custom text field for Vietnamese phone number input.
|
||||
/// Supports formats: 0xxx xxx xxx, +84xxx xxx xxx, 84xxx xxx xxx
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
|
||||
/// Phone Input Field
|
||||
///
|
||||
/// A custom text field widget specifically designed for Vietnamese phone number input.
|
||||
/// Features:
|
||||
/// - Phone icon prefix
|
||||
/// - Numeric keyboard
|
||||
/// - Phone number formatting
|
||||
/// - Vietnamese phone validation support
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// PhoneInputField(
|
||||
/// controller: phoneController,
|
||||
/// validator: Validators.phone,
|
||||
/// onChanged: (value) {
|
||||
/// // Handle phone number change
|
||||
/// },
|
||||
/// )
|
||||
/// ```
|
||||
class PhoneInputField extends StatelessWidget {
|
||||
/// Creates a phone input field
|
||||
const PhoneInputField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.validator,
|
||||
this.onChanged,
|
||||
this.onFieldSubmitted,
|
||||
this.enabled = true,
|
||||
this.autofocus = false,
|
||||
});
|
||||
|
||||
/// Text editing controller
|
||||
final TextEditingController controller;
|
||||
|
||||
/// Focus node for keyboard focus management
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// Form field validator
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
/// Callback when text changes
|
||||
final void Function(String)? onChanged;
|
||||
|
||||
/// Callback when field is submitted
|
||||
final void Function(String)? onFieldSubmitted;
|
||||
|
||||
/// Whether the field is enabled
|
||||
final bool enabled;
|
||||
|
||||
/// Whether the field should auto-focus
|
||||
final bool autofocus;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.next,
|
||||
inputFormatters: [
|
||||
// Allow digits, spaces, +, and parentheses
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9+\s()]')),
|
||||
// Limit to reasonable phone length
|
||||
LengthLimitingTextInputFormatter(15),
|
||||
],
|
||||
style: const TextStyle(
|
||||
fontSize: InputFieldSpecs.fontSize,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Số điện thoại',
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.labelFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
hintText: 'Nhập số điện thoại',
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.hintFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.phone,
|
||||
color: AppColors.primaryBlue,
|
||||
size: AppIconSize.md,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.primaryBlue,
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 1.0),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 2.0),
|
||||
),
|
||||
errorStyle: const TextStyle(fontSize: 12.0, color: AppColors.danger),
|
||||
),
|
||||
validator: validator,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/features/auth/presentation/widgets/role_dropdown.dart
Normal file
115
lib/features/auth/presentation/widgets/role_dropdown.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
/// Role Dropdown Widget
|
||||
///
|
||||
/// Dropdown for selecting user role during registration.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
|
||||
/// Role Dropdown
|
||||
///
|
||||
/// A custom dropdown widget for selecting user role.
|
||||
/// Roles:
|
||||
/// - dealer: Đại lý hệ thống
|
||||
/// - worker: Kiến trúc sư/ Thầu thợ
|
||||
/// - broker: Khách lẻ
|
||||
/// - other: Khác
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// RoleDropdown(
|
||||
/// value: selectedRole,
|
||||
/// onChanged: (value) {
|
||||
/// setState(() {
|
||||
/// selectedRole = value;
|
||||
/// });
|
||||
/// },
|
||||
/// validator: (value) {
|
||||
/// if (value == null || value.isEmpty) {
|
||||
/// return 'Vui lòng chọn vai trò';
|
||||
/// }
|
||||
/// return null;
|
||||
/// },
|
||||
/// )
|
||||
/// ```
|
||||
class RoleDropdown extends StatelessWidget {
|
||||
/// Creates a role dropdown
|
||||
const RoleDropdown({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
/// Selected role value
|
||||
final String? value;
|
||||
|
||||
/// Callback when role changes
|
||||
final void Function(String?) onChanged;
|
||||
|
||||
/// Form field validator
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DropdownButtonFormField<String>(
|
||||
value: value,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Chọn vai trò của bạn',
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: InputFieldSpecs.hintFontSize,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.work,
|
||||
color: AppColors.primaryBlue,
|
||||
size: AppIconSize.md,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
contentPadding: InputFieldSpecs.contentPadding,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.primaryBlue,
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 1.0),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
borderSide: const BorderSide(color: AppColors.danger, width: 2.0),
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'dealer', child: Text('Đại lý hệ thống')),
|
||||
DropdownMenuItem(
|
||||
value: 'worker',
|
||||
child: Text('Kiến trúc sư/ Thầu thợ'),
|
||||
),
|
||||
DropdownMenuItem(value: 'broker', child: Text('Khách lẻ')),
|
||||
DropdownMenuItem(value: 'other', child: Text('Khác')),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
validator: validator,
|
||||
icon: const Icon(Icons.arrow_drop_down, color: AppColors.grey500),
|
||||
dropdownColor: AppColors.white,
|
||||
style: const TextStyle(
|
||||
fontSize: InputFieldSpecs.fontSize,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -76,12 +76,7 @@ class Cart {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
cartId,
|
||||
userId,
|
||||
totalAmount,
|
||||
isSynced,
|
||||
);
|
||||
return Object.hash(cartId, userId, totalAmount, isSynced);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -48,19 +48,14 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
title: const Text('Xóa giỏ hàng'),
|
||||
content: const Text('Bạn có chắc chắn muốn xóa toàn bộ giỏ hàng?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('Hủy'),
|
||||
),
|
||||
TextButton(onPressed: () => context.pop(), child: const Text('Hủy')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(cartProvider.notifier).clearCart();
|
||||
context.pop();
|
||||
context.pop(); // Also go back from cart page
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.danger,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppColors.danger),
|
||||
child: const Text('Xóa'),
|
||||
),
|
||||
],
|
||||
@@ -86,7 +81,10 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Giỏ hàng ($itemCount)', style: const TextStyle(color: Colors.black)),
|
||||
title: Text(
|
||||
'Giỏ hàng ($itemCount)',
|
||||
style: const TextStyle(color: Colors.black),
|
||||
),
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
foregroundColor: AppColors.grey900,
|
||||
@@ -155,9 +153,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Hãy thêm sản phẩm vào giỏ hàng',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
@@ -283,9 +279,9 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_discountController.text.isNotEmpty) {
|
||||
ref.read(cartProvider.notifier).applyDiscountCode(
|
||||
_discountController.text,
|
||||
);
|
||||
ref
|
||||
.read(cartProvider.notifier)
|
||||
.applyDiscountCode(_discountController.text);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -326,7 +322,10 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
}
|
||||
|
||||
/// Build order summary section
|
||||
Widget _buildOrderSummary(CartState cartState, NumberFormat currencyFormatter) {
|
||||
Widget _buildOrderSummary(
|
||||
CartState cartState,
|
||||
NumberFormat currencyFormatter,
|
||||
) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -394,10 +393,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Phí vận chuyển',
|
||||
style: AppTypography.bodyMedium,
|
||||
),
|
||||
Text('Phí vận chuyển', style: AppTypography.bodyMedium),
|
||||
Text(
|
||||
cartState.shippingFee > 0
|
||||
? currencyFormatter.format(cartState.shippingFee)
|
||||
@@ -448,10 +444,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
||||
: null,
|
||||
child: const Text(
|
||||
'Tiến hành đặt hàng',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -142,11 +142,10 @@ class CheckoutPage extends HookConsumerWidget {
|
||||
|
||||
// Payment Method Section (hidden if negotiation is checked)
|
||||
if (!needsNegotiation.value)
|
||||
PaymentMethodSection(
|
||||
paymentMethod: paymentMethod,
|
||||
),
|
||||
PaymentMethodSection(paymentMethod: paymentMethod),
|
||||
|
||||
if (!needsNegotiation.value) const SizedBox(height: AppSpacing.md),
|
||||
if (!needsNegotiation.value)
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Order Summary Section
|
||||
OrderSummarySection(
|
||||
@@ -160,9 +159,7 @@ class CheckoutPage extends HookConsumerWidget {
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Price Negotiation Section
|
||||
PriceNegotiationSection(
|
||||
needsNegotiation: needsNegotiation,
|
||||
),
|
||||
PriceNegotiationSection(needsNegotiation: needsNegotiation),
|
||||
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
|
||||
@@ -44,14 +44,9 @@ class Cart extends _$Cart {
|
||||
);
|
||||
} else {
|
||||
// Add new item
|
||||
final newItem = CartItemData(
|
||||
product: product,
|
||||
quantity: quantity,
|
||||
);
|
||||
final newItem = CartItemData(product: product, quantity: quantity);
|
||||
|
||||
state = state.copyWith(
|
||||
items: [...state.items, newItem],
|
||||
);
|
||||
state = state.copyWith(items: [...state.items, newItem]);
|
||||
_recalculateTotal();
|
||||
}
|
||||
}
|
||||
@@ -59,7 +54,9 @@ class Cart extends _$Cart {
|
||||
/// Remove product from cart
|
||||
void removeFromCart(String productId) {
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => item.product.productId != productId).toList(),
|
||||
items: state.items
|
||||
.where((item) => item.product.productId != productId)
|
||||
.toList(),
|
||||
);
|
||||
_recalculateTotal();
|
||||
}
|
||||
@@ -113,20 +110,14 @@ class Cart extends _$Cart {
|
||||
// TODO: Validate with backend
|
||||
// For now, simulate discount application
|
||||
if (code.isNotEmpty) {
|
||||
state = state.copyWith(
|
||||
discountCode: code,
|
||||
discountCodeApplied: true,
|
||||
);
|
||||
state = state.copyWith(discountCode: code, discountCodeApplied: true);
|
||||
_recalculateTotal();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove discount code
|
||||
void removeDiscountCode() {
|
||||
state = state.copyWith(
|
||||
discountCode: null,
|
||||
discountCodeApplied: false,
|
||||
);
|
||||
state = state.copyWith(discountCode: null, discountCodeApplied: false);
|
||||
_recalculateTotal();
|
||||
}
|
||||
|
||||
@@ -157,10 +148,7 @@ class Cart extends _$Cart {
|
||||
|
||||
/// Get total quantity of all items
|
||||
double get totalQuantity {
|
||||
return state.items.fold<double>(
|
||||
0.0,
|
||||
(sum, item) => sum + item.quantity,
|
||||
);
|
||||
return state.items.fold<double>(0.0, (sum, item) => sum + item.quantity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,18 +12,12 @@ class CartItemData {
|
||||
final Product product;
|
||||
final double quantity;
|
||||
|
||||
const CartItemData({
|
||||
required this.product,
|
||||
required this.quantity,
|
||||
});
|
||||
const CartItemData({required this.product, required this.quantity});
|
||||
|
||||
/// Calculate line total
|
||||
double get lineTotal => product.basePrice * quantity;
|
||||
|
||||
CartItemData copyWith({
|
||||
Product? product,
|
||||
double? quantity,
|
||||
}) {
|
||||
CartItemData copyWith({Product? product, double? quantity}) {
|
||||
return CartItemData(
|
||||
product: product ?? this.product,
|
||||
quantity: quantity ?? this.quantity,
|
||||
@@ -101,7 +95,8 @@ class CartState {
|
||||
discountCode: discountCode ?? this.discountCode,
|
||||
discountCodeApplied: discountCodeApplied ?? this.discountCodeApplied,
|
||||
memberTier: memberTier ?? this.memberTier,
|
||||
memberDiscountPercent: memberDiscountPercent ?? this.memberDiscountPercent,
|
||||
memberDiscountPercent:
|
||||
memberDiscountPercent ?? this.memberDiscountPercent,
|
||||
subtotal: subtotal ?? this.subtotal,
|
||||
memberDiscount: memberDiscount ?? this.memberDiscount,
|
||||
shippingFee: shippingFee ?? this.shippingFee,
|
||||
|
||||
@@ -22,10 +22,7 @@ import 'package:worker/features/cart/presentation/providers/cart_state.dart';
|
||||
class CartItemWidget extends ConsumerWidget {
|
||||
final CartItemData item;
|
||||
|
||||
const CartItemWidget({
|
||||
super.key,
|
||||
required this.item,
|
||||
});
|
||||
const CartItemWidget({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -65,9 +62,7 @@ class CartItemWidget extends ConsumerWidget {
|
||||
height: 80,
|
||||
color: AppColors.grey100,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
@@ -129,9 +124,9 @@ class CartItemWidget extends ConsumerWidget {
|
||||
_QuantityButton(
|
||||
icon: Icons.remove,
|
||||
onPressed: () {
|
||||
ref.read(cartProvider.notifier).decrementQuantity(
|
||||
item.product.productId,
|
||||
);
|
||||
ref
|
||||
.read(cartProvider.notifier)
|
||||
.decrementQuantity(item.product.productId);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -151,9 +146,9 @@ class CartItemWidget extends ConsumerWidget {
|
||||
_QuantityButton(
|
||||
icon: Icons.add,
|
||||
onPressed: () {
|
||||
ref.read(cartProvider.notifier).incrementQuantity(
|
||||
item.product.productId,
|
||||
);
|
||||
ref
|
||||
.read(cartProvider.notifier)
|
||||
.incrementQuantity(item.product.productId);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -184,10 +179,7 @@ class _QuantityButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _QuantityButton({
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
const _QuantityButton({required this.icon, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -201,11 +193,7 @@ class _QuantityButton extends StatelessWidget {
|
||||
color: AppColors.grey100,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
child: Icon(icon, size: 18, color: AppColors.grey900),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,8 +68,11 @@ class CheckoutDatePickerField extends HookWidget {
|
||||
: AppColors.grey500.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.calendar_today,
|
||||
size: 20, color: AppColors.grey500),
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -75,10 +75,7 @@ class CheckoutDropdownField extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
items: items.map((item) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
);
|
||||
return DropdownMenuItem<String>(value: item, child: Text(item));
|
||||
}).toList(),
|
||||
onChanged: onChanged,
|
||||
validator: (value) {
|
||||
|
||||
@@ -114,7 +114,8 @@ class CheckoutSubmitButton extends StatelessWidget {
|
||||
});
|
||||
} else {
|
||||
// Generate order ID (mock - replace with actual from backend)
|
||||
final orderId = 'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
|
||||
final orderId =
|
||||
'DH${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
|
||||
|
||||
// Show order success message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -130,10 +131,7 @@ class CheckoutSubmitButton extends StatelessWidget {
|
||||
if (context.mounted) {
|
||||
context.pushNamed(
|
||||
RouteNames.paymentQr,
|
||||
queryParameters: {
|
||||
'orderId': orderId,
|
||||
'amount': total.toString(),
|
||||
},
|
||||
queryParameters: {'orderId': orderId, 'amount': total.toString()},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,13 +103,7 @@ class DeliveryInformationSection extends HookWidget {
|
||||
label: 'Tỉnh/Thành phố',
|
||||
value: selectedProvince.value,
|
||||
required: true,
|
||||
items: const [
|
||||
'TP.HCM',
|
||||
'Hà Nội',
|
||||
'Đà Nẵng',
|
||||
'Cần Thơ',
|
||||
'Biên Hòa',
|
||||
],
|
||||
items: const ['TP.HCM', 'Hà Nội', 'Đà Nẵng', 'Cần Thơ', 'Biên Hòa'],
|
||||
onChanged: (value) {
|
||||
selectedProvince.value = value;
|
||||
},
|
||||
|
||||
@@ -132,8 +132,9 @@ class InvoiceSection extends HookWidget {
|
||||
return 'Vui lòng nhập email';
|
||||
}
|
||||
if (needsInvoice.value &&
|
||||
!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
|
||||
.hasMatch(value!)) {
|
||||
!RegExp(
|
||||
r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$',
|
||||
).hasMatch(value!)) {
|
||||
return 'Email không hợp lệ';
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -148,8 +148,10 @@ class OrderSummarySection extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Mã: ${item['sku']}',
|
||||
style:
|
||||
const TextStyle(fontSize: 12, color: AppColors.grey500),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -168,8 +170,9 @@ class OrderSummarySection extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatCurrency(
|
||||
((item['price'] as int) * (item['quantity'] as int))
|
||||
.toDouble()),
|
||||
((item['price'] as int) * (item['quantity'] as int))
|
||||
.toDouble(),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -184,8 +187,11 @@ class OrderSummarySection extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Build summary row
|
||||
Widget _buildSummaryRow(String label, double amount,
|
||||
{bool isDiscount = false}) {
|
||||
Widget _buildSummaryRow(
|
||||
String label,
|
||||
double amount, {
|
||||
bool isDiscount = false,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -207,9 +213,6 @@ class OrderSummarySection extends StatelessWidget {
|
||||
|
||||
/// Format currency
|
||||
String _formatCurrency(double amount) {
|
||||
return '${amount.toStringAsFixed(0).replaceAllMapped(
|
||||
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]}.',
|
||||
)}₫';
|
||||
return '${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}₫';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@ import 'package:worker/core/theme/colors.dart';
|
||||
class PaymentMethodSection extends HookWidget {
|
||||
final ValueNotifier<String> paymentMethod;
|
||||
|
||||
const PaymentMethodSection({
|
||||
super.key,
|
||||
required this.paymentMethod,
|
||||
});
|
||||
const PaymentMethodSection({super.key, required this.paymentMethod});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -72,13 +69,17 @@ class PaymentMethodSection extends HookWidget {
|
||||
Text(
|
||||
'Chuyển khoản ngân hàng',
|
||||
style: TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w500),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Thanh toán qua chuyển khoản',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -112,13 +113,17 @@ class PaymentMethodSection extends HookWidget {
|
||||
Text(
|
||||
'Thanh toán khi nhận hàng (COD)',
|
||||
style: TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w500),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Thanh toán bằng tiền mặt khi nhận hàng',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -14,10 +14,7 @@ import 'package:worker/core/theme/colors.dart';
|
||||
class PriceNegotiationSection extends HookWidget {
|
||||
final ValueNotifier<bool> needsNegotiation;
|
||||
|
||||
const PriceNegotiationSection({
|
||||
super.key,
|
||||
required this.needsNegotiation,
|
||||
});
|
||||
const PriceNegotiationSection({super.key, required this.needsNegotiation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -7,18 +7,39 @@ part 'chat_room_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.chatRoomModel)
|
||||
class ChatRoomModel extends HiveObject {
|
||||
ChatRoomModel({required this.chatRoomId, required this.roomType, this.relatedQuoteId, this.relatedOrderId, required this.participants, this.roomName, required this.isActive, this.lastActivity, required this.createdAt, this.createdBy});
|
||||
|
||||
@HiveField(0) final String chatRoomId;
|
||||
@HiveField(1) final RoomType roomType;
|
||||
@HiveField(2) final String? relatedQuoteId;
|
||||
@HiveField(3) final String? relatedOrderId;
|
||||
@HiveField(4) final String participants;
|
||||
@HiveField(5) final String? roomName;
|
||||
@HiveField(6) final bool isActive;
|
||||
@HiveField(7) final DateTime? lastActivity;
|
||||
@HiveField(8) final DateTime createdAt;
|
||||
@HiveField(9) final String? createdBy;
|
||||
ChatRoomModel({
|
||||
required this.chatRoomId,
|
||||
required this.roomType,
|
||||
this.relatedQuoteId,
|
||||
this.relatedOrderId,
|
||||
required this.participants,
|
||||
this.roomName,
|
||||
required this.isActive,
|
||||
this.lastActivity,
|
||||
required this.createdAt,
|
||||
this.createdBy,
|
||||
});
|
||||
|
||||
@HiveField(0)
|
||||
final String chatRoomId;
|
||||
@HiveField(1)
|
||||
final RoomType roomType;
|
||||
@HiveField(2)
|
||||
final String? relatedQuoteId;
|
||||
@HiveField(3)
|
||||
final String? relatedOrderId;
|
||||
@HiveField(4)
|
||||
final String participants;
|
||||
@HiveField(5)
|
||||
final String? roomName;
|
||||
@HiveField(6)
|
||||
final bool isActive;
|
||||
@HiveField(7)
|
||||
final DateTime? lastActivity;
|
||||
@HiveField(8)
|
||||
final DateTime createdAt;
|
||||
@HiveField(9)
|
||||
final String? createdBy;
|
||||
|
||||
factory ChatRoomModel.fromJson(Map<String, dynamic> json) => ChatRoomModel(
|
||||
chatRoomId: json['chat_room_id'] as String,
|
||||
@@ -28,7 +49,9 @@ class ChatRoomModel extends HiveObject {
|
||||
participants: jsonEncode(json['participants']),
|
||||
roomName: json['room_name'] as String?,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
lastActivity: json['last_activity'] != null ? DateTime.parse(json['last_activity']?.toString() ?? '') : null,
|
||||
lastActivity: json['last_activity'] != null
|
||||
? DateTime.parse(json['last_activity']?.toString() ?? '')
|
||||
: null,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
createdBy: json['created_by'] as String?,
|
||||
);
|
||||
|
||||
@@ -7,27 +7,56 @@ part 'message_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.messageModel)
|
||||
class MessageModel extends HiveObject {
|
||||
MessageModel({required this.messageId, required this.chatRoomId, required this.senderId, required this.contentType, required this.content, this.attachmentUrl, this.productReference, required this.isRead, required this.isEdited, required this.isDeleted, this.readBy, required this.timestamp, this.editedAt});
|
||||
|
||||
@HiveField(0) final String messageId;
|
||||
@HiveField(1) final String chatRoomId;
|
||||
@HiveField(2) final String senderId;
|
||||
@HiveField(3) final ContentType contentType;
|
||||
@HiveField(4) final String content;
|
||||
@HiveField(5) final String? attachmentUrl;
|
||||
@HiveField(6) final String? productReference;
|
||||
@HiveField(7) final bool isRead;
|
||||
@HiveField(8) final bool isEdited;
|
||||
@HiveField(9) final bool isDeleted;
|
||||
@HiveField(10) final String? readBy;
|
||||
@HiveField(11) final DateTime timestamp;
|
||||
@HiveField(12) final DateTime? editedAt;
|
||||
MessageModel({
|
||||
required this.messageId,
|
||||
required this.chatRoomId,
|
||||
required this.senderId,
|
||||
required this.contentType,
|
||||
required this.content,
|
||||
this.attachmentUrl,
|
||||
this.productReference,
|
||||
required this.isRead,
|
||||
required this.isEdited,
|
||||
required this.isDeleted,
|
||||
this.readBy,
|
||||
required this.timestamp,
|
||||
this.editedAt,
|
||||
});
|
||||
|
||||
@HiveField(0)
|
||||
final String messageId;
|
||||
@HiveField(1)
|
||||
final String chatRoomId;
|
||||
@HiveField(2)
|
||||
final String senderId;
|
||||
@HiveField(3)
|
||||
final ContentType contentType;
|
||||
@HiveField(4)
|
||||
final String content;
|
||||
@HiveField(5)
|
||||
final String? attachmentUrl;
|
||||
@HiveField(6)
|
||||
final String? productReference;
|
||||
@HiveField(7)
|
||||
final bool isRead;
|
||||
@HiveField(8)
|
||||
final bool isEdited;
|
||||
@HiveField(9)
|
||||
final bool isDeleted;
|
||||
@HiveField(10)
|
||||
final String? readBy;
|
||||
@HiveField(11)
|
||||
final DateTime timestamp;
|
||||
@HiveField(12)
|
||||
final DateTime? editedAt;
|
||||
|
||||
factory MessageModel.fromJson(Map<String, dynamic> json) => MessageModel(
|
||||
messageId: json['message_id'] as String,
|
||||
chatRoomId: json['chat_room_id'] as String,
|
||||
senderId: json['sender_id'] as String,
|
||||
contentType: ContentType.values.firstWhere((e) => e.name == json['content_type']),
|
||||
contentType: ContentType.values.firstWhere(
|
||||
(e) => e.name == json['content_type'],
|
||||
),
|
||||
content: json['content'] as String,
|
||||
attachmentUrl: json['attachment_url'] as String?,
|
||||
productReference: json['product_reference'] as String?,
|
||||
@@ -36,7 +65,9 @@ class MessageModel extends HiveObject {
|
||||
isDeleted: json['is_deleted'] as bool? ?? false,
|
||||
readBy: json['read_by'] != null ? jsonEncode(json['read_by']) : null,
|
||||
timestamp: DateTime.parse(json['timestamp']?.toString() ?? ''),
|
||||
editedAt: json['edited_at'] != null ? DateTime.parse(json['edited_at']?.toString() ?? '') : null,
|
||||
editedAt: json['edited_at'] != null
|
||||
? DateTime.parse(json['edited_at']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
||||
@@ -117,8 +117,7 @@ class Message {
|
||||
bool get isSystemMessage => contentType == ContentType.system;
|
||||
|
||||
/// Check if message has attachment
|
||||
bool get hasAttachment =>
|
||||
attachmentUrl != null && attachmentUrl!.isNotEmpty;
|
||||
bool get hasAttachment => attachmentUrl != null && attachmentUrl!.isNotEmpty;
|
||||
|
||||
/// Check if message references a product
|
||||
bool get hasProductReference =>
|
||||
|
||||
@@ -112,10 +112,16 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Tìm kiếm cuộc trò chuyện...',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.grey500),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, color: AppColors.grey500),
|
||||
icon: const Icon(
|
||||
Icons.clear,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchController.clear();
|
||||
@@ -148,7 +154,10 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
|
||||
// Conversation 1 - Order Reference
|
||||
_ConversationItem(
|
||||
avatarIcon: Icons.inventory_2,
|
||||
avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue],
|
||||
avatarGradient: const [
|
||||
AppColors.primaryBlue,
|
||||
AppColors.lightBlue,
|
||||
],
|
||||
contactName: 'Đơn hàng #SO001234',
|
||||
messageTime: '14:30',
|
||||
lastMessage: 'Đơn hàng đang được giao - Dự kiến đến 16:00',
|
||||
@@ -183,7 +192,10 @@ class _ChatListPageState extends ConsumerState<ChatListPage> {
|
||||
// Conversation 3 - Support Team
|
||||
_ConversationItem(
|
||||
avatarIcon: Icons.headset_mic,
|
||||
avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue],
|
||||
avatarGradient: const [
|
||||
AppColors.primaryBlue,
|
||||
AppColors.lightBlue,
|
||||
],
|
||||
contactName: 'Tổng đài hỗ trợ',
|
||||
messageTime: '13:45',
|
||||
lastMessage: 'Thông tin về quy trình đổi trả sản phẩm',
|
||||
@@ -319,8 +331,8 @@ class _ConversationItem extends StatelessWidget {
|
||||
color: isOnline
|
||||
? AppColors.success
|
||||
: isAway
|
||||
? AppColors.warning
|
||||
: AppColors.grey500,
|
||||
? AppColors.warning
|
||||
: AppColors.grey500,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.white, width: 2),
|
||||
),
|
||||
|
||||
@@ -40,7 +40,9 @@ class FavoritesLocalDataSource {
|
||||
Future<void> addFavorite(FavoriteModel favorite) async {
|
||||
try {
|
||||
await _box.put(favorite.favoriteId, favorite);
|
||||
debugPrint('[FavoritesLocalDataSource] Added favorite: ${favorite.favoriteId} for user: ${favorite.userId}');
|
||||
debugPrint(
|
||||
'[FavoritesLocalDataSource] Added favorite: ${favorite.favoriteId} for user: ${favorite.userId}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[FavoritesLocalDataSource] Error adding favorite: $e');
|
||||
rethrow;
|
||||
@@ -60,13 +62,17 @@ class FavoritesLocalDataSource {
|
||||
.toList();
|
||||
|
||||
if (favorites.isEmpty) {
|
||||
debugPrint('[FavoritesLocalDataSource] Favorite not found: productId=$productId, userId=$userId');
|
||||
debugPrint(
|
||||
'[FavoritesLocalDataSource] Favorite not found: productId=$productId, userId=$userId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final favorite = favorites.first;
|
||||
await _box.delete(favorite.favoriteId);
|
||||
debugPrint('[FavoritesLocalDataSource] Removed favorite: ${favorite.favoriteId} for user: $userId');
|
||||
debugPrint(
|
||||
'[FavoritesLocalDataSource] Removed favorite: ${favorite.favoriteId} for user: $userId',
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[FavoritesLocalDataSource] Error removing favorite: $e');
|
||||
@@ -79,9 +85,9 @@ class FavoritesLocalDataSource {
|
||||
/// Returns true if the product is in the user's favorites, false otherwise.
|
||||
bool isFavorite(String productId, String userId) {
|
||||
try {
|
||||
return _box.values
|
||||
.whereType<FavoriteModel>()
|
||||
.any((fav) => fav.productId == productId && fav.userId == userId);
|
||||
return _box.values.whereType<FavoriteModel>().any(
|
||||
(fav) => fav.productId == productId && fav.userId == userId,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[FavoritesLocalDataSource] Error checking favorite: $e');
|
||||
return false;
|
||||
@@ -101,7 +107,9 @@ class FavoritesLocalDataSource {
|
||||
.toList();
|
||||
|
||||
await _box.deleteAll(favoriteIds);
|
||||
debugPrint('[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId');
|
||||
debugPrint(
|
||||
'[FavoritesLocalDataSource] Cleared ${favoriteIds.length} favorites for user: $userId',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[FavoritesLocalDataSource] Error clearing favorites: $e');
|
||||
rethrow;
|
||||
@@ -140,7 +148,9 @@ class FavoritesLocalDataSource {
|
||||
debugPrint('[FavoritesLocalDataSource] Favorites box compacted');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[FavoritesLocalDataSource] Error compacting favorites box: $e');
|
||||
debugPrint(
|
||||
'[FavoritesLocalDataSource] Error compacting favorites box: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,11 +62,6 @@ class Favorite {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
favoriteId,
|
||||
productId,
|
||||
userId,
|
||||
createdAt,
|
||||
);
|
||||
return Object.hash(favoriteId, productId, userId, createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ class FavoritesPage extends ConsumerWidget {
|
||||
const FavoritesPage({super.key});
|
||||
|
||||
/// Show confirmation dialog before clearing all favorites
|
||||
Future<void> _showClearAllDialog(BuildContext context, WidgetRef ref, int count) async {
|
||||
Future<void> _showClearAllDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
int count,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -185,10 +189,7 @@ class _EmptyState extends StatelessWidget {
|
||||
// Subtext
|
||||
Text(
|
||||
'Thêm sản phẩm vào danh sách yêu thích để xem lại sau',
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
@@ -213,10 +214,7 @@ class _EmptyState extends StatelessWidget {
|
||||
),
|
||||
child: const Text(
|
||||
'Khám phá sản phẩm',
|
||||
style: TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -351,10 +349,7 @@ class _ErrorState extends StatelessWidget {
|
||||
final Object error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _ErrorState({
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
});
|
||||
const _ErrorState({required this.error, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -389,10 +384,7 @@ class _ErrorState extends StatelessWidget {
|
||||
// Error message
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -417,10 +409,7 @@ class _ErrorState extends StatelessWidget {
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text(
|
||||
'Thử lại',
|
||||
style: TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -440,9 +429,7 @@ class _ErrorState extends StatelessWidget {
|
||||
class _FavoritesGrid extends StatelessWidget {
|
||||
final List<Product> products;
|
||||
|
||||
const _FavoritesGrid({
|
||||
required this.products,
|
||||
});
|
||||
const _FavoritesGrid({required this.products});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -457,9 +444,7 @@ class _FavoritesGrid extends StatelessWidget {
|
||||
itemCount: products.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
return RepaintBoundary(
|
||||
child: FavoriteProductCard(product: product),
|
||||
);
|
||||
return RepaintBoundary(child: FavoriteProductCard(product: product));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,7 +260,9 @@ Future<List<Product>> favoriteProducts(Ref ref) async {
|
||||
final allProducts = await getProductsUseCase();
|
||||
|
||||
// Filter to only include favorited products
|
||||
return allProducts.where((product) => favoriteIds.contains(product.productId)).toList();
|
||||
return allProducts
|
||||
.where((product) => favoriteIds.contains(product.productId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -22,10 +22,7 @@ import 'package:worker/features/products/domain/entities/product.dart';
|
||||
class FavoriteProductCard extends ConsumerWidget {
|
||||
final Product product;
|
||||
|
||||
const FavoriteProductCard({
|
||||
super.key,
|
||||
required this.product,
|
||||
});
|
||||
const FavoriteProductCard({super.key, required this.product});
|
||||
|
||||
String _formatPrice(double price) {
|
||||
final formatter = NumberFormat('#,###', 'vi_VN');
|
||||
@@ -60,7 +57,9 @@ class FavoriteProductCard extends ConsumerWidget {
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
// Remove from favorites
|
||||
await ref.read(favoritesProvider.notifier).removeFavorite(product.productId);
|
||||
await ref
|
||||
.read(favoritesProvider.notifier)
|
||||
.removeFavorite(product.productId);
|
||||
|
||||
// Show snackbar
|
||||
if (context.mounted) {
|
||||
@@ -103,9 +102,7 @@ class FavoriteProductCard extends ConsumerWidget {
|
||||
placeholder: (context, url) => Shimmer.fromColors(
|
||||
baseColor: AppColors.grey100,
|
||||
highlightColor: AppColors.grey50,
|
||||
child: Container(
|
||||
color: AppColors.grey100,
|
||||
),
|
||||
child: Container(color: AppColors.grey100),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: AppColors.grey100,
|
||||
|
||||
@@ -82,7 +82,7 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource {
|
||||
'tier': 'diamond',
|
||||
'points': 9750,
|
||||
'validUntil': '2025-12-31T23:59:59.000Z',
|
||||
'qrData': '0983441099'
|
||||
'qrData': '0983441099',
|
||||
};
|
||||
|
||||
/// Mock JSON data for promotions
|
||||
@@ -115,7 +115,7 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource {
|
||||
'startDate': '2025-01-01T00:00:00.000Z',
|
||||
'endDate': '2025-12-31T23:59:59.000Z',
|
||||
'discountPercentage': 5,
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/// Constructor
|
||||
|
||||
@@ -15,7 +15,7 @@ enum PromotionStatus {
|
||||
upcoming,
|
||||
|
||||
/// Expired promotion
|
||||
expired;
|
||||
expired,
|
||||
}
|
||||
|
||||
/// Promotion Entity
|
||||
|
||||
@@ -222,7 +222,6 @@ class HomePage extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,7 @@ class MemberCardWidget extends StatelessWidget {
|
||||
/// Member card data
|
||||
final MemberCard memberCard;
|
||||
|
||||
const MemberCardWidget({
|
||||
super.key,
|
||||
required this.memberCard,
|
||||
});
|
||||
const MemberCardWidget({super.key, required this.memberCard});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -185,8 +182,8 @@ class MemberCardWidget extends StatelessWidget {
|
||||
/// Format points with thousands separator
|
||||
String _formatPoints(int points) {
|
||||
return points.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]},',
|
||||
);
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]},',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ import 'package:worker/features/home/domain/entities/promotion.dart';
|
||||
/// Displays a horizontal scrollable list of promotion cards.
|
||||
/// Each card shows an image, title, and brief description.
|
||||
class PromotionSlider extends StatelessWidget {
|
||||
|
||||
const PromotionSlider({
|
||||
super.key,
|
||||
required this.promotions,
|
||||
this.onPromotionTap,
|
||||
});
|
||||
|
||||
/// List of promotions to display
|
||||
final List<Promotion> promotions;
|
||||
|
||||
@@ -83,11 +83,7 @@ class PromotionSlider extends StatelessWidget {
|
||||
|
||||
/// Individual Promotion Card
|
||||
class _PromotionCard extends StatelessWidget {
|
||||
|
||||
const _PromotionCard({
|
||||
required this.promotion,
|
||||
this.onTap,
|
||||
});
|
||||
const _PromotionCard({required this.promotion, this.onTap});
|
||||
final Promotion promotion;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@@ -115,8 +111,9 @@ class _PromotionCard extends StatelessWidget {
|
||||
children: [
|
||||
// Promotion Image
|
||||
ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: promotion.imageUrl,
|
||||
height: 140,
|
||||
@@ -125,9 +122,7 @@ class _PromotionCard extends StatelessWidget {
|
||||
placeholder: (context, url) => Container(
|
||||
height: 140,
|
||||
color: AppColors.grey100,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
height: 140,
|
||||
|
||||
@@ -63,11 +63,7 @@ class QuickActionItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Icon
|
||||
Icon(
|
||||
icon,
|
||||
size: 32,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
Icon(icon, size: 32, color: AppColors.primaryBlue),
|
||||
const SizedBox(height: 8),
|
||||
// Label
|
||||
Text(
|
||||
@@ -97,9 +93,7 @@ class QuickActionItem extends StatelessWidget {
|
||||
color: AppColors.danger,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 20),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: const TextStyle(
|
||||
|
||||
@@ -31,9 +31,7 @@ class PointsHistoryLocalDataSource {
|
||||
/// Get all points entries
|
||||
Future<List<LoyaltyPointEntryModel>> getAllEntries() async {
|
||||
final box = await entriesBox;
|
||||
final entries = box.values
|
||||
.whereType<LoyaltyPointEntryModel>()
|
||||
.toList();
|
||||
final entries = box.values.whereType<LoyaltyPointEntryModel>().toList();
|
||||
entries.sort((a, b) => b.timestamp.compareTo(a.timestamp)); // Newest first
|
||||
return entries;
|
||||
}
|
||||
@@ -42,9 +40,9 @@ class PointsHistoryLocalDataSource {
|
||||
Future<LoyaltyPointEntryModel?> getEntryById(String entryId) async {
|
||||
final box = await entriesBox;
|
||||
try {
|
||||
return box.values
|
||||
.whereType<LoyaltyPointEntryModel>()
|
||||
.firstWhere((entry) => entry.entryId == entryId);
|
||||
return box.values.whereType<LoyaltyPointEntryModel>().firstWhere(
|
||||
(entry) => entry.entryId == entryId,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Entry not found');
|
||||
}
|
||||
|
||||
@@ -6,41 +6,81 @@ part 'gift_catalog_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.giftCatalogModel)
|
||||
class GiftCatalogModel extends HiveObject {
|
||||
GiftCatalogModel({required this.catalogId, required this.name, required this.description, this.imageUrl, required this.category, required this.pointsCost, required this.cashValue, required this.quantityAvailable, required this.quantityRedeemed, this.termsConditions, required this.isActive, this.validFrom, this.validUntil, required this.createdAt, this.updatedAt});
|
||||
|
||||
@HiveField(0) final String catalogId;
|
||||
@HiveField(1) final String name;
|
||||
@HiveField(2) final String description;
|
||||
@HiveField(3) final String? imageUrl;
|
||||
@HiveField(4) final GiftCategory category;
|
||||
@HiveField(5) final int pointsCost;
|
||||
@HiveField(6) final double cashValue;
|
||||
@HiveField(7) final int quantityAvailable;
|
||||
@HiveField(8) final int quantityRedeemed;
|
||||
@HiveField(9) final String? termsConditions;
|
||||
@HiveField(10) final bool isActive;
|
||||
@HiveField(11) final DateTime? validFrom;
|
||||
@HiveField(12) final DateTime? validUntil;
|
||||
@HiveField(13) final DateTime createdAt;
|
||||
@HiveField(14) final DateTime? updatedAt;
|
||||
GiftCatalogModel({
|
||||
required this.catalogId,
|
||||
required this.name,
|
||||
required this.description,
|
||||
this.imageUrl,
|
||||
required this.category,
|
||||
required this.pointsCost,
|
||||
required this.cashValue,
|
||||
required this.quantityAvailable,
|
||||
required this.quantityRedeemed,
|
||||
this.termsConditions,
|
||||
required this.isActive,
|
||||
this.validFrom,
|
||||
this.validUntil,
|
||||
required this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory GiftCatalogModel.fromJson(Map<String, dynamic> json) => GiftCatalogModel(
|
||||
catalogId: json['catalog_id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
category: GiftCategory.values.firstWhere((e) => e.name == json['category']),
|
||||
pointsCost: json['points_cost'] as int,
|
||||
cashValue: (json['cash_value'] as num).toDouble(),
|
||||
quantityAvailable: json['quantity_available'] as int,
|
||||
quantityRedeemed: json['quantity_redeemed'] as int? ?? 0,
|
||||
termsConditions: json['terms_conditions'] as String?,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
validFrom: json['valid_from'] != null ? DateTime.parse(json['valid_from']?.toString() ?? '') : null,
|
||||
validUntil: json['valid_until'] != null ? DateTime.parse(json['valid_until']?.toString() ?? '') : null,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null,
|
||||
);
|
||||
@HiveField(0)
|
||||
final String catalogId;
|
||||
@HiveField(1)
|
||||
final String name;
|
||||
@HiveField(2)
|
||||
final String description;
|
||||
@HiveField(3)
|
||||
final String? imageUrl;
|
||||
@HiveField(4)
|
||||
final GiftCategory category;
|
||||
@HiveField(5)
|
||||
final int pointsCost;
|
||||
@HiveField(6)
|
||||
final double cashValue;
|
||||
@HiveField(7)
|
||||
final int quantityAvailable;
|
||||
@HiveField(8)
|
||||
final int quantityRedeemed;
|
||||
@HiveField(9)
|
||||
final String? termsConditions;
|
||||
@HiveField(10)
|
||||
final bool isActive;
|
||||
@HiveField(11)
|
||||
final DateTime? validFrom;
|
||||
@HiveField(12)
|
||||
final DateTime? validUntil;
|
||||
@HiveField(13)
|
||||
final DateTime createdAt;
|
||||
@HiveField(14)
|
||||
final DateTime? updatedAt;
|
||||
|
||||
factory GiftCatalogModel.fromJson(Map<String, dynamic> json) =>
|
||||
GiftCatalogModel(
|
||||
catalogId: json['catalog_id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
category: GiftCategory.values.firstWhere(
|
||||
(e) => e.name == json['category'],
|
||||
),
|
||||
pointsCost: json['points_cost'] as int,
|
||||
cashValue: (json['cash_value'] as num).toDouble(),
|
||||
quantityAvailable: json['quantity_available'] as int,
|
||||
quantityRedeemed: json['quantity_redeemed'] as int? ?? 0,
|
||||
termsConditions: json['terms_conditions'] as String?,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
validFrom: json['valid_from'] != null
|
||||
? DateTime.parse(json['valid_from']?.toString() ?? '')
|
||||
: null,
|
||||
validUntil: json['valid_until'] != null
|
||||
? DateTime.parse(json['valid_until']?.toString() ?? '')
|
||||
: null,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'catalog_id': catalogId,
|
||||
|
||||
@@ -7,24 +7,55 @@ part 'loyalty_point_entry_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.loyaltyPointEntryModel)
|
||||
class LoyaltyPointEntryModel extends HiveObject {
|
||||
LoyaltyPointEntryModel({required this.entryId, required this.userId, required this.points, required this.entryType, required this.source, required this.description, this.referenceId, this.referenceType, this.complaint, required this.complaintStatus, required this.balanceAfter, this.expiryDate, required this.timestamp, this.erpnextEntryId});
|
||||
|
||||
@HiveField(0) final String entryId;
|
||||
@HiveField(1) final String userId;
|
||||
@HiveField(2) final int points;
|
||||
@HiveField(3) final EntryType entryType;
|
||||
@HiveField(4) final EntrySource source;
|
||||
@HiveField(5) final String description;
|
||||
@HiveField(6) final String? referenceId;
|
||||
@HiveField(7) final String? referenceType;
|
||||
@HiveField(8) final String? complaint;
|
||||
@HiveField(9) final ComplaintStatus complaintStatus;
|
||||
@HiveField(10) final int balanceAfter;
|
||||
@HiveField(11) final DateTime? expiryDate;
|
||||
@HiveField(12) final DateTime timestamp;
|
||||
@HiveField(13) final String? erpnextEntryId;
|
||||
LoyaltyPointEntryModel({
|
||||
required this.entryId,
|
||||
required this.userId,
|
||||
required this.points,
|
||||
required this.entryType,
|
||||
required this.source,
|
||||
required this.description,
|
||||
this.referenceId,
|
||||
this.referenceType,
|
||||
this.complaint,
|
||||
required this.complaintStatus,
|
||||
required this.balanceAfter,
|
||||
this.expiryDate,
|
||||
required this.timestamp,
|
||||
this.erpnextEntryId,
|
||||
});
|
||||
|
||||
factory LoyaltyPointEntryModel.fromJson(Map<String, dynamic> json) => LoyaltyPointEntryModel(
|
||||
@HiveField(0)
|
||||
final String entryId;
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
@HiveField(2)
|
||||
final int points;
|
||||
@HiveField(3)
|
||||
final EntryType entryType;
|
||||
@HiveField(4)
|
||||
final EntrySource source;
|
||||
@HiveField(5)
|
||||
final String description;
|
||||
@HiveField(6)
|
||||
final String? referenceId;
|
||||
@HiveField(7)
|
||||
final String? referenceType;
|
||||
@HiveField(8)
|
||||
final String? complaint;
|
||||
@HiveField(9)
|
||||
final ComplaintStatus complaintStatus;
|
||||
@HiveField(10)
|
||||
final int balanceAfter;
|
||||
@HiveField(11)
|
||||
final DateTime? expiryDate;
|
||||
@HiveField(12)
|
||||
final DateTime timestamp;
|
||||
@HiveField(13)
|
||||
final String? erpnextEntryId;
|
||||
|
||||
factory LoyaltyPointEntryModel.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => LoyaltyPointEntryModel(
|
||||
entryId: json['entry_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
points: json['points'] as int,
|
||||
@@ -34,9 +65,13 @@ class LoyaltyPointEntryModel extends HiveObject {
|
||||
referenceId: json['reference_id'] as String?,
|
||||
referenceType: json['reference_type'] as String?,
|
||||
complaint: json['complaint'] != null ? jsonEncode(json['complaint']) : null,
|
||||
complaintStatus: ComplaintStatus.values.firstWhere((e) => e.name == (json['complaint_status'] ?? 'none')),
|
||||
complaintStatus: ComplaintStatus.values.firstWhere(
|
||||
(e) => e.name == (json['complaint_status'] ?? 'none'),
|
||||
),
|
||||
balanceAfter: json['balance_after'] as int,
|
||||
expiryDate: json['expiry_date'] != null ? DateTime.parse(json['expiry_date']?.toString() ?? '') : null,
|
||||
expiryDate: json['expiry_date'] != null
|
||||
? DateTime.parse(json['expiry_date']?.toString() ?? '')
|
||||
: null,
|
||||
timestamp: DateTime.parse(json['timestamp']?.toString() ?? ''),
|
||||
erpnextEntryId: json['erpnext_entry_id'] as String?,
|
||||
);
|
||||
@@ -67,6 +102,7 @@ class LoyaltyPointEntryModel extends HiveObject {
|
||||
}
|
||||
}
|
||||
|
||||
bool get isExpired => expiryDate != null && DateTime.now().isAfter(expiryDate!);
|
||||
bool get isExpired =>
|
||||
expiryDate != null && DateTime.now().isAfter(expiryDate!);
|
||||
bool get hasComplaint => complaintStatus != ComplaintStatus.none;
|
||||
}
|
||||
|
||||
@@ -7,39 +7,75 @@ part 'points_record_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.pointsRecordModel)
|
||||
class PointsRecordModel extends HiveObject {
|
||||
PointsRecordModel({required this.recordId, required this.userId, required this.invoiceNumber, required this.storeName, required this.transactionDate, required this.invoiceAmount, this.notes, this.attachments, required this.status, this.rejectReason, this.pointsEarned, required this.submittedAt, this.processedAt, this.processedBy});
|
||||
|
||||
@HiveField(0) final String recordId;
|
||||
@HiveField(1) final String userId;
|
||||
@HiveField(2) final String invoiceNumber;
|
||||
@HiveField(3) final String storeName;
|
||||
@HiveField(4) final DateTime transactionDate;
|
||||
@HiveField(5) final double invoiceAmount;
|
||||
@HiveField(6) final String? notes;
|
||||
@HiveField(7) final String? attachments;
|
||||
@HiveField(8) final PointsStatus status;
|
||||
@HiveField(9) final String? rejectReason;
|
||||
@HiveField(10) final int? pointsEarned;
|
||||
@HiveField(11) final DateTime submittedAt;
|
||||
@HiveField(12) final DateTime? processedAt;
|
||||
@HiveField(13) final String? processedBy;
|
||||
PointsRecordModel({
|
||||
required this.recordId,
|
||||
required this.userId,
|
||||
required this.invoiceNumber,
|
||||
required this.storeName,
|
||||
required this.transactionDate,
|
||||
required this.invoiceAmount,
|
||||
this.notes,
|
||||
this.attachments,
|
||||
required this.status,
|
||||
this.rejectReason,
|
||||
this.pointsEarned,
|
||||
required this.submittedAt,
|
||||
this.processedAt,
|
||||
this.processedBy,
|
||||
});
|
||||
|
||||
factory PointsRecordModel.fromJson(Map<String, dynamic> json) => PointsRecordModel(
|
||||
recordId: json['record_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
invoiceNumber: json['invoice_number'] as String,
|
||||
storeName: json['store_name'] as String,
|
||||
transactionDate: DateTime.parse(json['transaction_date']?.toString() ?? ''),
|
||||
invoiceAmount: (json['invoice_amount'] as num).toDouble(),
|
||||
notes: json['notes'] as String?,
|
||||
attachments: json['attachments'] != null ? jsonEncode(json['attachments']) : null,
|
||||
status: PointsStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
rejectReason: json['reject_reason'] as String?,
|
||||
pointsEarned: json['points_earned'] as int?,
|
||||
submittedAt: DateTime.parse(json['submitted_at']?.toString() ?? ''),
|
||||
processedAt: json['processed_at'] != null ? DateTime.parse(json['processed_at']?.toString() ?? '') : null,
|
||||
processedBy: json['processed_by'] as String?,
|
||||
);
|
||||
@HiveField(0)
|
||||
final String recordId;
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
@HiveField(2)
|
||||
final String invoiceNumber;
|
||||
@HiveField(3)
|
||||
final String storeName;
|
||||
@HiveField(4)
|
||||
final DateTime transactionDate;
|
||||
@HiveField(5)
|
||||
final double invoiceAmount;
|
||||
@HiveField(6)
|
||||
final String? notes;
|
||||
@HiveField(7)
|
||||
final String? attachments;
|
||||
@HiveField(8)
|
||||
final PointsStatus status;
|
||||
@HiveField(9)
|
||||
final String? rejectReason;
|
||||
@HiveField(10)
|
||||
final int? pointsEarned;
|
||||
@HiveField(11)
|
||||
final DateTime submittedAt;
|
||||
@HiveField(12)
|
||||
final DateTime? processedAt;
|
||||
@HiveField(13)
|
||||
final String? processedBy;
|
||||
|
||||
factory PointsRecordModel.fromJson(Map<String, dynamic> json) =>
|
||||
PointsRecordModel(
|
||||
recordId: json['record_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
invoiceNumber: json['invoice_number'] as String,
|
||||
storeName: json['store_name'] as String,
|
||||
transactionDate: DateTime.parse(
|
||||
json['transaction_date']?.toString() ?? '',
|
||||
),
|
||||
invoiceAmount: (json['invoice_amount'] as num).toDouble(),
|
||||
notes: json['notes'] as String?,
|
||||
attachments: json['attachments'] != null
|
||||
? jsonEncode(json['attachments'])
|
||||
: null,
|
||||
status: PointsStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
rejectReason: json['reject_reason'] as String?,
|
||||
pointsEarned: json['points_earned'] as int?,
|
||||
submittedAt: DateTime.parse(json['submitted_at']?.toString() ?? ''),
|
||||
processedAt: json['processed_at'] != null
|
||||
? DateTime.parse(json['processed_at']?.toString() ?? '')
|
||||
: null,
|
||||
processedBy: json['processed_by'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'record_id': recordId,
|
||||
|
||||
@@ -6,43 +6,83 @@ part 'redeemed_gift_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.redeemedGiftModel)
|
||||
class RedeemedGiftModel extends HiveObject {
|
||||
RedeemedGiftModel({required this.giftId, required this.userId, required this.catalogId, required this.name, required this.description, this.voucherCode, this.qrCodeImage, required this.giftType, required this.pointsCost, required this.cashValue, this.expiryDate, required this.status, required this.redeemedAt, this.usedAt, this.usedLocation, this.usedReference});
|
||||
|
||||
@HiveField(0) final String giftId;
|
||||
@HiveField(1) final String userId;
|
||||
@HiveField(2) final String catalogId;
|
||||
@HiveField(3) final String name;
|
||||
@HiveField(4) final String description;
|
||||
@HiveField(5) final String? voucherCode;
|
||||
@HiveField(6) final String? qrCodeImage;
|
||||
@HiveField(7) final GiftCategory giftType;
|
||||
@HiveField(8) final int pointsCost;
|
||||
@HiveField(9) final double cashValue;
|
||||
@HiveField(10) final DateTime? expiryDate;
|
||||
@HiveField(11) final GiftStatus status;
|
||||
@HiveField(12) final DateTime redeemedAt;
|
||||
@HiveField(13) final DateTime? usedAt;
|
||||
@HiveField(14) final String? usedLocation;
|
||||
@HiveField(15) final String? usedReference;
|
||||
RedeemedGiftModel({
|
||||
required this.giftId,
|
||||
required this.userId,
|
||||
required this.catalogId,
|
||||
required this.name,
|
||||
required this.description,
|
||||
this.voucherCode,
|
||||
this.qrCodeImage,
|
||||
required this.giftType,
|
||||
required this.pointsCost,
|
||||
required this.cashValue,
|
||||
this.expiryDate,
|
||||
required this.status,
|
||||
required this.redeemedAt,
|
||||
this.usedAt,
|
||||
this.usedLocation,
|
||||
this.usedReference,
|
||||
});
|
||||
|
||||
factory RedeemedGiftModel.fromJson(Map<String, dynamic> json) => RedeemedGiftModel(
|
||||
giftId: json['gift_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
catalogId: json['catalog_id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
voucherCode: json['voucher_code'] as String?,
|
||||
qrCodeImage: json['qr_code_image'] as String?,
|
||||
giftType: GiftCategory.values.firstWhere((e) => e.name == json['gift_type']),
|
||||
pointsCost: json['points_cost'] as int,
|
||||
cashValue: (json['cash_value'] as num).toDouble(),
|
||||
expiryDate: json['expiry_date'] != null ? DateTime.parse(json['expiry_date']?.toString() ?? '') : null,
|
||||
status: GiftStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
redeemedAt: DateTime.parse(json['redeemed_at']?.toString() ?? ''),
|
||||
usedAt: json['used_at'] != null ? DateTime.parse(json['used_at']?.toString() ?? '') : null,
|
||||
usedLocation: json['used_location'] as String?,
|
||||
usedReference: json['used_reference'] as String?,
|
||||
);
|
||||
@HiveField(0)
|
||||
final String giftId;
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
@HiveField(2)
|
||||
final String catalogId;
|
||||
@HiveField(3)
|
||||
final String name;
|
||||
@HiveField(4)
|
||||
final String description;
|
||||
@HiveField(5)
|
||||
final String? voucherCode;
|
||||
@HiveField(6)
|
||||
final String? qrCodeImage;
|
||||
@HiveField(7)
|
||||
final GiftCategory giftType;
|
||||
@HiveField(8)
|
||||
final int pointsCost;
|
||||
@HiveField(9)
|
||||
final double cashValue;
|
||||
@HiveField(10)
|
||||
final DateTime? expiryDate;
|
||||
@HiveField(11)
|
||||
final GiftStatus status;
|
||||
@HiveField(12)
|
||||
final DateTime redeemedAt;
|
||||
@HiveField(13)
|
||||
final DateTime? usedAt;
|
||||
@HiveField(14)
|
||||
final String? usedLocation;
|
||||
@HiveField(15)
|
||||
final String? usedReference;
|
||||
|
||||
factory RedeemedGiftModel.fromJson(Map<String, dynamic> json) =>
|
||||
RedeemedGiftModel(
|
||||
giftId: json['gift_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
catalogId: json['catalog_id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
voucherCode: json['voucher_code'] as String?,
|
||||
qrCodeImage: json['qr_code_image'] as String?,
|
||||
giftType: GiftCategory.values.firstWhere(
|
||||
(e) => e.name == json['gift_type'],
|
||||
),
|
||||
pointsCost: json['points_cost'] as int,
|
||||
cashValue: (json['cash_value'] as num).toDouble(),
|
||||
expiryDate: json['expiry_date'] != null
|
||||
? DateTime.parse(json['expiry_date']?.toString() ?? '')
|
||||
: null,
|
||||
status: GiftStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
redeemedAt: DateTime.parse(json['redeemed_at']?.toString() ?? ''),
|
||||
usedAt: json['used_at'] != null
|
||||
? DateTime.parse(json['used_at']?.toString() ?? '')
|
||||
: null,
|
||||
usedLocation: json['used_location'] as String?,
|
||||
usedReference: json['used_reference'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'gift_id': giftId,
|
||||
@@ -63,7 +103,8 @@ class RedeemedGiftModel extends HiveObject {
|
||||
'used_reference': usedReference,
|
||||
};
|
||||
|
||||
bool get isExpired => expiryDate != null && DateTime.now().isAfter(expiryDate!);
|
||||
bool get isExpired =>
|
||||
expiryDate != null && DateTime.now().isAfter(expiryDate!);
|
||||
bool get isUsed => status == GiftStatus.used;
|
||||
bool get isActive => status == GiftStatus.active && !isExpired;
|
||||
}
|
||||
|
||||
@@ -194,13 +194,7 @@ class GiftCatalog {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
catalogId,
|
||||
name,
|
||||
category,
|
||||
pointsCost,
|
||||
isActive,
|
||||
);
|
||||
return Object.hash(catalogId, name, category, pointsCost, isActive);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -15,7 +15,7 @@ enum EntryType {
|
||||
adjustment,
|
||||
|
||||
/// Points expired
|
||||
expiry;
|
||||
expiry,
|
||||
}
|
||||
|
||||
/// Entry source enum
|
||||
@@ -45,7 +45,7 @@ enum EntrySource {
|
||||
welcome,
|
||||
|
||||
/// Other source
|
||||
other;
|
||||
other,
|
||||
}
|
||||
|
||||
/// Complaint status enum
|
||||
@@ -63,7 +63,7 @@ enum ComplaintStatus {
|
||||
approved,
|
||||
|
||||
/// Complaint rejected
|
||||
rejected;
|
||||
rejected,
|
||||
}
|
||||
|
||||
/// Loyalty Point Entry Entity
|
||||
|
||||
@@ -164,13 +164,7 @@ class PointsRecord {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
recordId,
|
||||
userId,
|
||||
invoiceNumber,
|
||||
invoiceAmount,
|
||||
status,
|
||||
);
|
||||
return Object.hash(recordId, userId, invoiceNumber, invoiceAmount, status);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -190,13 +190,7 @@ class RedeemedGift {
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
giftId,
|
||||
userId,
|
||||
catalogId,
|
||||
voucherCode,
|
||||
status,
|
||||
);
|
||||
return Object.hash(giftId, userId, catalogId, voucherCode, status);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -204,9 +204,7 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
return Card(
|
||||
elevation: 5,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -228,17 +226,11 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
'Hạng hiện tại: DIAMOND',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
),
|
||||
Text(
|
||||
'Hạng kế tiếp: PLATINUM',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -265,10 +257,7 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
child: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: const TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
children: [
|
||||
TextSpan(text: 'Còn '),
|
||||
TextSpan(
|
||||
@@ -414,9 +403,7 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -436,7 +423,10 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
_buildBenefitItem('Ưu tiên xử lý đơn hàng'),
|
||||
_buildBenefitItem('Tặng 500 điểm vào ngày sinh nhật'),
|
||||
_buildBenefitItem('Tư vấn thiết kế miễn phí'),
|
||||
_buildBenefitItem('Mời tham gia sự kiện VIP độc quyền', isLast: true),
|
||||
_buildBenefitItem(
|
||||
'Mời tham gia sự kiện VIP độc quyền',
|
||||
isLast: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -450,11 +440,7 @@ class LoyaltyPage extends ConsumerWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 20,
|
||||
color: Color(0xFF4A00E0),
|
||||
),
|
||||
const Icon(Icons.check_circle, size: 20, color: Color(0xFF4A00E0)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
||||
@@ -65,7 +65,9 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Transaction List
|
||||
...entries.map((entry) => _buildTransactionCard(context, ref, entry)),
|
||||
...entries.map(
|
||||
(entry) => _buildTransactionCard(context, ref, entry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -82,9 +84,7 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -101,20 +101,13 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.filter_list,
|
||||
color: AppColors.primaryBlue,
|
||||
size: 20,
|
||||
),
|
||||
Icon(Icons.filter_list, color: AppColors.primaryBlue, size: 20),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Thời gian hiệu lực: 01/01/2023 - 31/12/2023',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 12, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -137,14 +130,14 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
|
||||
// Get transaction amount if it's a purchase
|
||||
final datasource = ref.read(pointsHistoryLocalDataSourceProvider);
|
||||
final transactionAmount = datasource.getTransactionAmount(entry.description);
|
||||
final transactionAmount = datasource.getTransactionAmount(
|
||||
entry.description,
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 1,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -199,11 +192,16 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Chức năng khiếu nại đang phát triển')),
|
||||
const SnackBar(
|
||||
content: Text('Chức năng khiếu nại đang phát triển'),
|
||||
),
|
||||
);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
side: const BorderSide(color: AppColors.grey500),
|
||||
foregroundColor: AppColors.grey900,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
@@ -235,8 +233,8 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
color: entry.points > 0
|
||||
? AppColors.success
|
||||
: entry.points < 0
|
||||
? AppColors.danger
|
||||
: AppColors.grey900,
|
||||
? AppColors.danger
|
||||
: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
@@ -282,10 +280,7 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Kéo xuống để làm mới',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -316,10 +311,7 @@ class PointsHistoryPage extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -37,13 +37,20 @@ class RewardsPage extends ConsumerWidget {
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text('Đổi quà tặng', style: TextStyle(color: Colors.black)),
|
||||
title: const Text(
|
||||
'Đổi quà tặng',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
foregroundColor: AppColors.grey900,
|
||||
centerTitle: false,
|
||||
actions: const [
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, color: Colors.black),
|
||||
onPressed: () => _showInfoDialog(context),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
@@ -72,26 +79,20 @@ class RewardsPage extends ConsumerWidget {
|
||||
sliver: filteredGifts.isEmpty
|
||||
? _buildEmptyState()
|
||||
: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.7,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final gift = filteredGifts[index];
|
||||
return RewardCard(
|
||||
gift: gift,
|
||||
onRedeem: () => _handleRedeemGift(
|
||||
context,
|
||||
ref,
|
||||
gift,
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: filteredGifts.length,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.7,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final gift = filteredGifts[index];
|
||||
return RewardCard(
|
||||
gift: gift,
|
||||
onRedeem: () => _handleRedeemGift(context, ref, gift),
|
||||
);
|
||||
}, childCount: filteredGifts.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -100,6 +101,84 @@ class RewardsPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Show info dialog with usage instructions
|
||||
void _showInfoDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text(
|
||||
'Hướng dẫn sử dụng',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Đây là nội dung hướng dẫn sử dụng cho tính năng Đổi quà tặng:',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoItem(
|
||||
'Sử dụng điểm tích lũy của bạn để đổi các phần quà giá trị trong danh mục.',
|
||||
),
|
||||
_buildInfoItem(
|
||||
'Bấm vào một phần quà để xem chi tiết và điều kiện áp dụng.',
|
||||
),
|
||||
_buildInfoItem(
|
||||
'Khi xác nhận đổi quà, bạn có thể chọn "Nhận hàng tại Showroom".',
|
||||
),
|
||||
_buildInfoItem(
|
||||
'Nếu chọn "Nhận hàng tại Showroom", bạn sẽ cần chọn Showroom bạn muốn đến nhận từ danh sách thả xuống.',
|
||||
),
|
||||
_buildInfoItem(
|
||||
'Quà đã đổi sẽ được chuyển vào mục "Quà của tôi" (trong trang Hội viên).',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 44),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('Đóng'),
|
||||
),
|
||||
],
|
||||
actionsPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
contentPadding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build info item with bullet point
|
||||
Widget _buildInfoItem(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6),
|
||||
child: Icon(Icons.circle, size: 6, color: AppColors.grey500),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(text, style: TextStyle(fontSize: 14, height: 1.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build category filter pills
|
||||
Widget _buildCategoryFilter(
|
||||
BuildContext context,
|
||||
@@ -237,10 +316,7 @@ class RewardsPage extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Vui lòng thử lại sau',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -290,10 +366,7 @@ class RewardsPage extends ConsumerWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Chi phí:',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const Text('Chi phí:', style: TextStyle(fontSize: 13)),
|
||||
Text(
|
||||
'${numberFormat.format(gift.pointsCost)} điểm',
|
||||
style: const TextStyle(
|
||||
@@ -363,9 +436,7 @@ class RewardsPage extends ConsumerWidget {
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text('Đổi quà "${gift.name}" thành công!'),
|
||||
),
|
||||
Expanded(child: Text('Đổi quà "${gift.name}" thành công!')),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.success,
|
||||
|
||||
@@ -38,7 +38,9 @@ class PointsHistory extends _$PointsHistory {
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await ref.read(pointsHistoryLocalDataSourceProvider).getAllEntries();
|
||||
return await ref
|
||||
.read(pointsHistoryLocalDataSourceProvider)
|
||||
.getAllEntries();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,25 +22,17 @@ class RewardCard extends ConsumerWidget {
|
||||
/// Callback when redeem button is pressed
|
||||
final VoidCallback onRedeem;
|
||||
|
||||
const RewardCard({
|
||||
required this.gift,
|
||||
required this.onRedeem,
|
||||
super.key,
|
||||
});
|
||||
const RewardCard({required this.gift, required this.onRedeem, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasEnoughPoints = ref.watch(
|
||||
hasEnoughPointsProvider(gift.pointsCost),
|
||||
);
|
||||
final hasEnoughPoints = ref.watch(hasEnoughPointsProvider(gift.pointsCost));
|
||||
final numberFormat = NumberFormat('#,###', 'vi_VN');
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -158,9 +150,7 @@ class RewardCard extends ConsumerWidget {
|
||||
placeholder: (context, url) => Container(
|
||||
color: AppColors.grey100,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
|
||||
@@ -50,7 +50,11 @@ class MainScaffold extends ConsumerWidget {
|
||||
onPressed: () => context.push(RouteNames.chat),
|
||||
backgroundColor: const Color(0xFF35C6F4), // Accent cyan color
|
||||
elevation: 4,
|
||||
child: const Icon(Icons.chat_bubble, color: AppColors.white, size: 28),
|
||||
child: const Icon(
|
||||
Icons.chat_bubble,
|
||||
color: AppColors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -58,7 +62,11 @@ class MainScaffold extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, -2)),
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
@@ -75,9 +83,18 @@ class MainScaffold extends ConsumerWidget {
|
||||
currentIndex: currentIndex,
|
||||
elevation: 0,
|
||||
items: [
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Trang chủ'),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.loyalty), label: 'Hội viên'),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.local_offer), label: 'Tin tức'),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home),
|
||||
label: 'Trang chủ',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.loyalty),
|
||||
label: 'Hội viên',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.local_offer),
|
||||
label: 'Tin tức',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
@@ -87,12 +104,25 @@ class MainScaffold extends ConsumerWidget {
|
||||
top: -4,
|
||||
right: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: AppColors.danger, borderRadius: BorderRadius.circular(12)),
|
||||
constraints: const BoxConstraints(minWidth: 20, minHeight: 20),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.danger,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
),
|
||||
child: const Text(
|
||||
'5',
|
||||
style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -101,7 +131,10 @@ class MainScaffold extends ConsumerWidget {
|
||||
),
|
||||
label: 'Thông báo',
|
||||
),
|
||||
const BottomNavigationBarItem(icon: Icon(Icons.account_circle), label: 'Cài đặt'),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.account_circle),
|
||||
label: 'Cài đặt',
|
||||
),
|
||||
],
|
||||
onTap: (index) {
|
||||
ref.read(currentPageIndexProvider.notifier).setIndex(index);
|
||||
|
||||
@@ -66,7 +66,7 @@ class NotificationLocalDataSource {
|
||||
'data': {
|
||||
'current_tier': 'gold',
|
||||
'next_tier': 'platinum',
|
||||
'points_needed': 2250
|
||||
'points_needed': 2250,
|
||||
},
|
||||
'is_read': true,
|
||||
'is_pushed': true,
|
||||
@@ -128,17 +128,20 @@ class NotificationLocalDataSource {
|
||||
|
||||
/// Get notifications by category
|
||||
Future<List<Map<String, dynamic>>> getNotificationsByCategory(
|
||||
String category) async {
|
||||
String category,
|
||||
) async {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
if (category == 'general') {
|
||||
return _mockNotifications
|
||||
.where((n) =>
|
||||
!(n['type'] as String).toLowerCase().contains('order') ||
|
||||
(n['type'] as String).toLowerCase().contains('loyalty') ||
|
||||
(n['type'] as String).toLowerCase().contains('promotion') ||
|
||||
(n['type'] as String).toLowerCase().contains('event') ||
|
||||
(n['type'] as String).toLowerCase().contains('birthday'))
|
||||
.where(
|
||||
(n) =>
|
||||
!(n['type'] as String).toLowerCase().contains('order') ||
|
||||
(n['type'] as String).toLowerCase().contains('loyalty') ||
|
||||
(n['type'] as String).toLowerCase().contains('promotion') ||
|
||||
(n['type'] as String).toLowerCase().contains('event') ||
|
||||
(n['type'] as String).toLowerCase().contains('birthday'),
|
||||
)
|
||||
.toList();
|
||||
} else if (category == 'order') {
|
||||
return _mockNotifications
|
||||
@@ -159,8 +162,9 @@ class NotificationLocalDataSource {
|
||||
Future<void> markAsRead(String notificationId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
|
||||
final index = _mockNotifications
|
||||
.indexWhere((n) => n['notification_id'] == notificationId);
|
||||
final index = _mockNotifications.indexWhere(
|
||||
(n) => n['notification_id'] == notificationId,
|
||||
);
|
||||
if (index != -1) {
|
||||
_mockNotifications[index]['is_read'] = true;
|
||||
_mockNotifications[index]['read_at'] = DateTime.now().toIso8601String();
|
||||
|
||||
@@ -21,7 +21,7 @@ class NotificationModel {
|
||||
isRead: json['is_read'] as bool? ?? false,
|
||||
isPushed: json['is_pushed'] as bool? ?? false,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
readAt: json['read_at'] != null
|
||||
readAt: json['read_at'] != null
|
||||
? DateTime.parse(json['read_at'] as String)
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -21,7 +21,9 @@ class NotificationRepositoryImpl implements NotificationRepository {
|
||||
Future<List<Notification>> getAllNotifications() async {
|
||||
final jsonList = await localDataSource.getAllNotifications();
|
||||
return jsonList.map((json) => NotificationModel.fromJson(json)).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Sort by newest first
|
||||
..sort(
|
||||
(a, b) => b.createdAt.compareTo(a.createdAt),
|
||||
); // Sort by newest first
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -60,7 +60,8 @@ class Notification {
|
||||
bool get isOrderNotification => type.toLowerCase().contains('order');
|
||||
|
||||
/// Check if notification is loyalty-related
|
||||
bool get isLoyaltyNotification => type.toLowerCase().contains('loyalty') ||
|
||||
bool get isLoyaltyNotification =>
|
||||
type.toLowerCase().contains('loyalty') ||
|
||||
type.toLowerCase().contains('points');
|
||||
|
||||
/// Check if notification is promotion-related
|
||||
|
||||
@@ -27,7 +27,9 @@ class InvoicesLocalDataSource {
|
||||
.map((json) => InvoiceModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
debugPrint('[InvoicesLocalDataSource] Loaded ${invoices.length} invoices');
|
||||
debugPrint(
|
||||
'[InvoicesLocalDataSource] Loaded ${invoices.length} invoices',
|
||||
);
|
||||
return invoices;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[InvoicesLocalDataSource] Error loading invoices: $e');
|
||||
@@ -65,8 +67,11 @@ class InvoicesLocalDataSource {
|
||||
final filtered = allInvoices
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.invoiceNumber.toLowerCase().contains(query.toLowerCase()) ||
|
||||
(invoice.orderId?.toLowerCase().contains(query.toLowerCase()) ?? false),
|
||||
invoice.invoiceNumber.toLowerCase().contains(
|
||||
query.toLowerCase(),
|
||||
) ||
|
||||
(invoice.orderId?.toLowerCase().contains(query.toLowerCase()) ??
|
||||
false),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@@ -89,7 +94,9 @@ class InvoicesLocalDataSource {
|
||||
orElse: () => throw Exception('Invoice not found: $invoiceId'),
|
||||
);
|
||||
|
||||
debugPrint('[InvoicesLocalDataSource] Found invoice: ${invoice.invoiceNumber}');
|
||||
debugPrint(
|
||||
'[InvoicesLocalDataSource] Found invoice: ${invoice.invoiceNumber}',
|
||||
);
|
||||
return invoice;
|
||||
} catch (e) {
|
||||
debugPrint('[InvoicesLocalDataSource] Error getting invoice: $e');
|
||||
@@ -105,10 +112,14 @@ class InvoicesLocalDataSource {
|
||||
.where((invoice) => invoice.isOverdue)
|
||||
.toList();
|
||||
|
||||
debugPrint('[InvoicesLocalDataSource] Found ${overdue.length} overdue invoices');
|
||||
debugPrint(
|
||||
'[InvoicesLocalDataSource] Found ${overdue.length} overdue invoices',
|
||||
);
|
||||
return overdue;
|
||||
} catch (e) {
|
||||
debugPrint('[InvoicesLocalDataSource] Error getting overdue invoices: $e');
|
||||
debugPrint(
|
||||
'[InvoicesLocalDataSource] Error getting overdue invoices: $e',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -118,10 +129,14 @@ class InvoicesLocalDataSource {
|
||||
try {
|
||||
final allInvoices = await getAllInvoices();
|
||||
final unpaid = allInvoices
|
||||
.where((invoice) => invoice.status.name == 'issued' && !invoice.isPaid)
|
||||
.where(
|
||||
(invoice) => invoice.status.name == 'issued' && !invoice.isPaid,
|
||||
)
|
||||
.toList();
|
||||
|
||||
debugPrint('[InvoicesLocalDataSource] Found ${unpaid.length} unpaid invoices');
|
||||
debugPrint(
|
||||
'[InvoicesLocalDataSource] Found ${unpaid.length} unpaid invoices',
|
||||
);
|
||||
return unpaid;
|
||||
} catch (e) {
|
||||
debugPrint('[InvoicesLocalDataSource] Error getting unpaid invoices: $e');
|
||||
|
||||
@@ -6,37 +6,84 @@ part 'invoice_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.invoiceModel)
|
||||
class InvoiceModel extends HiveObject {
|
||||
InvoiceModel({required this.invoiceId, required this.invoiceNumber, required this.userId, this.orderId, required this.invoiceType, required this.issueDate, required this.dueDate, required this.currency, required this.subtotalAmount, required this.taxAmount, required this.discountAmount, required this.shippingAmount, required this.totalAmount, required this.amountPaid, required this.amountRemaining, required this.status, this.paymentTerms, this.notes, this.erpnextInvoice, required this.createdAt, this.updatedAt, this.lastReminderSent});
|
||||
|
||||
@HiveField(0) final String invoiceId;
|
||||
@HiveField(1) final String invoiceNumber;
|
||||
@HiveField(2) final String userId;
|
||||
@HiveField(3) final String? orderId;
|
||||
@HiveField(4) final InvoiceType invoiceType;
|
||||
@HiveField(5) final DateTime issueDate;
|
||||
@HiveField(6) final DateTime dueDate;
|
||||
@HiveField(7) final String currency;
|
||||
@HiveField(8) final double subtotalAmount;
|
||||
@HiveField(9) final double taxAmount;
|
||||
@HiveField(10) final double discountAmount;
|
||||
@HiveField(11) final double shippingAmount;
|
||||
@HiveField(12) final double totalAmount;
|
||||
@HiveField(13) final double amountPaid;
|
||||
@HiveField(14) final double amountRemaining;
|
||||
@HiveField(15) final InvoiceStatus status;
|
||||
@HiveField(16) final String? paymentTerms;
|
||||
@HiveField(17) final String? notes;
|
||||
@HiveField(18) final String? erpnextInvoice;
|
||||
@HiveField(19) final DateTime createdAt;
|
||||
@HiveField(20) final DateTime? updatedAt;
|
||||
@HiveField(21) final DateTime? lastReminderSent;
|
||||
InvoiceModel({
|
||||
required this.invoiceId,
|
||||
required this.invoiceNumber,
|
||||
required this.userId,
|
||||
this.orderId,
|
||||
required this.invoiceType,
|
||||
required this.issueDate,
|
||||
required this.dueDate,
|
||||
required this.currency,
|
||||
required this.subtotalAmount,
|
||||
required this.taxAmount,
|
||||
required this.discountAmount,
|
||||
required this.shippingAmount,
|
||||
required this.totalAmount,
|
||||
required this.amountPaid,
|
||||
required this.amountRemaining,
|
||||
required this.status,
|
||||
this.paymentTerms,
|
||||
this.notes,
|
||||
this.erpnextInvoice,
|
||||
required this.createdAt,
|
||||
this.updatedAt,
|
||||
this.lastReminderSent,
|
||||
});
|
||||
|
||||
@HiveField(0)
|
||||
final String invoiceId;
|
||||
@HiveField(1)
|
||||
final String invoiceNumber;
|
||||
@HiveField(2)
|
||||
final String userId;
|
||||
@HiveField(3)
|
||||
final String? orderId;
|
||||
@HiveField(4)
|
||||
final InvoiceType invoiceType;
|
||||
@HiveField(5)
|
||||
final DateTime issueDate;
|
||||
@HiveField(6)
|
||||
final DateTime dueDate;
|
||||
@HiveField(7)
|
||||
final String currency;
|
||||
@HiveField(8)
|
||||
final double subtotalAmount;
|
||||
@HiveField(9)
|
||||
final double taxAmount;
|
||||
@HiveField(10)
|
||||
final double discountAmount;
|
||||
@HiveField(11)
|
||||
final double shippingAmount;
|
||||
@HiveField(12)
|
||||
final double totalAmount;
|
||||
@HiveField(13)
|
||||
final double amountPaid;
|
||||
@HiveField(14)
|
||||
final double amountRemaining;
|
||||
@HiveField(15)
|
||||
final InvoiceStatus status;
|
||||
@HiveField(16)
|
||||
final String? paymentTerms;
|
||||
@HiveField(17)
|
||||
final String? notes;
|
||||
@HiveField(18)
|
||||
final String? erpnextInvoice;
|
||||
@HiveField(19)
|
||||
final DateTime createdAt;
|
||||
@HiveField(20)
|
||||
final DateTime? updatedAt;
|
||||
@HiveField(21)
|
||||
final DateTime? lastReminderSent;
|
||||
|
||||
factory InvoiceModel.fromJson(Map<String, dynamic> json) => InvoiceModel(
|
||||
invoiceId: json['invoice_id'] as String,
|
||||
invoiceNumber: json['invoice_number'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
orderId: json['order_id'] as String?,
|
||||
invoiceType: InvoiceType.values.firstWhere((e) => e.name == json['invoice_type']),
|
||||
invoiceType: InvoiceType.values.firstWhere(
|
||||
(e) => e.name == json['invoice_type'],
|
||||
),
|
||||
issueDate: DateTime.parse(json['issue_date']?.toString() ?? ''),
|
||||
dueDate: DateTime.parse(json['due_date']?.toString() ?? ''),
|
||||
currency: json['currency'] as String? ?? 'VND',
|
||||
@@ -52,8 +99,12 @@ class InvoiceModel extends HiveObject {
|
||||
notes: json['notes'] as String?,
|
||||
erpnextInvoice: json['erpnext_invoice'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null,
|
||||
lastReminderSent: json['last_reminder_sent'] != null ? DateTime.parse(json['last_reminder_sent']?.toString() ?? '') : null,
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at']?.toString() ?? '')
|
||||
: null,
|
||||
lastReminderSent: json['last_reminder_sent'] != null
|
||||
? DateTime.parse(json['last_reminder_sent']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -81,6 +132,7 @@ class InvoiceModel extends HiveObject {
|
||||
'last_reminder_sent': lastReminderSent?.toIso8601String(),
|
||||
};
|
||||
|
||||
bool get isOverdue => DateTime.now().isAfter(dueDate) && status != InvoiceStatus.paid;
|
||||
bool get isOverdue =>
|
||||
DateTime.now().isAfter(dueDate) && status != InvoiceStatus.paid;
|
||||
bool get isPaid => status == InvoiceStatus.paid;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,33 @@ part 'order_item_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.orderItemModel)
|
||||
class OrderItemModel extends HiveObject {
|
||||
OrderItemModel({required this.orderItemId, required this.orderId, required this.productId, required this.quantity, required this.unitPrice, required this.discountPercent, required this.subtotal, this.notes});
|
||||
|
||||
@HiveField(0) final String orderItemId;
|
||||
@HiveField(1) final String orderId;
|
||||
@HiveField(2) final String productId;
|
||||
@HiveField(3) final double quantity;
|
||||
@HiveField(4) final double unitPrice;
|
||||
@HiveField(5) final double discountPercent;
|
||||
@HiveField(6) final double subtotal;
|
||||
@HiveField(7) final String? notes;
|
||||
OrderItemModel({
|
||||
required this.orderItemId,
|
||||
required this.orderId,
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
required this.discountPercent,
|
||||
required this.subtotal,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
@HiveField(0)
|
||||
final String orderItemId;
|
||||
@HiveField(1)
|
||||
final String orderId;
|
||||
@HiveField(2)
|
||||
final String productId;
|
||||
@HiveField(3)
|
||||
final double quantity;
|
||||
@HiveField(4)
|
||||
final double unitPrice;
|
||||
@HiveField(5)
|
||||
final double discountPercent;
|
||||
@HiveField(6)
|
||||
final double subtotal;
|
||||
@HiveField(7)
|
||||
final String? notes;
|
||||
|
||||
factory OrderItemModel.fromJson(Map<String, dynamic> json) => OrderItemModel(
|
||||
orderItemId: json['order_item_id'] as String,
|
||||
|
||||
@@ -94,15 +94,25 @@ class OrderModel extends HiveObject {
|
||||
taxAmount: (json['tax_amount'] as num).toDouble(),
|
||||
shippingFee: (json['shipping_fee'] as num).toDouble(),
|
||||
finalAmount: (json['final_amount'] as num).toDouble(),
|
||||
shippingAddress: json['shipping_address'] != null ? jsonEncode(json['shipping_address']) : null,
|
||||
billingAddress: json['billing_address'] != null ? jsonEncode(json['billing_address']) : null,
|
||||
expectedDeliveryDate: json['expected_delivery_date'] != null ? DateTime.parse(json['expected_delivery_date']?.toString() ?? '') : null,
|
||||
actualDeliveryDate: json['actual_delivery_date'] != null ? DateTime.parse(json['actual_delivery_date']?.toString() ?? '') : null,
|
||||
shippingAddress: json['shipping_address'] != null
|
||||
? jsonEncode(json['shipping_address'])
|
||||
: null,
|
||||
billingAddress: json['billing_address'] != null
|
||||
? jsonEncode(json['billing_address'])
|
||||
: null,
|
||||
expectedDeliveryDate: json['expected_delivery_date'] != null
|
||||
? DateTime.parse(json['expected_delivery_date']?.toString() ?? '')
|
||||
: null,
|
||||
actualDeliveryDate: json['actual_delivery_date'] != null
|
||||
? DateTime.parse(json['actual_delivery_date']?.toString() ?? '')
|
||||
: null,
|
||||
notes: json['notes'] as String?,
|
||||
cancellationReason: json['cancellation_reason'] as String?,
|
||||
erpnextSalesOrder: json['erpnext_sales_order'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null,
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,8 +126,12 @@ class OrderModel extends HiveObject {
|
||||
'tax_amount': taxAmount,
|
||||
'shipping_fee': shippingFee,
|
||||
'final_amount': finalAmount,
|
||||
'shipping_address': shippingAddress != null ? jsonDecode(shippingAddress!) : null,
|
||||
'billing_address': billingAddress != null ? jsonDecode(billingAddress!) : null,
|
||||
'shipping_address': shippingAddress != null
|
||||
? jsonDecode(shippingAddress!)
|
||||
: null,
|
||||
'billing_address': billingAddress != null
|
||||
? jsonDecode(billingAddress!)
|
||||
: null,
|
||||
'expected_delivery_date': expectedDeliveryDate?.toIso8601String(),
|
||||
'actual_delivery_date': actualDeliveryDate?.toIso8601String(),
|
||||
'notes': notes,
|
||||
|
||||
@@ -6,41 +6,79 @@ part 'payment_line_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.paymentLineModel)
|
||||
class PaymentLineModel extends HiveObject {
|
||||
PaymentLineModel({required this.paymentLineId, required this.invoiceId, required this.paymentNumber, required this.paymentDate, required this.amount, required this.paymentMethod, this.bankName, this.bankAccount, this.referenceNumber, this.notes, required this.status, this.receiptUrl, this.erpnextPaymentEntry, required this.createdAt, this.processedAt});
|
||||
|
||||
@HiveField(0) final String paymentLineId;
|
||||
@HiveField(1) final String invoiceId;
|
||||
@HiveField(2) final String paymentNumber;
|
||||
@HiveField(3) final DateTime paymentDate;
|
||||
@HiveField(4) final double amount;
|
||||
@HiveField(5) final PaymentMethod paymentMethod;
|
||||
@HiveField(6) final String? bankName;
|
||||
@HiveField(7) final String? bankAccount;
|
||||
@HiveField(8) final String? referenceNumber;
|
||||
@HiveField(9) final String? notes;
|
||||
@HiveField(10) final PaymentStatus status;
|
||||
@HiveField(11) final String? receiptUrl;
|
||||
@HiveField(12) final String? erpnextPaymentEntry;
|
||||
@HiveField(13) final DateTime createdAt;
|
||||
@HiveField(14) final DateTime? processedAt;
|
||||
PaymentLineModel({
|
||||
required this.paymentLineId,
|
||||
required this.invoiceId,
|
||||
required this.paymentNumber,
|
||||
required this.paymentDate,
|
||||
required this.amount,
|
||||
required this.paymentMethod,
|
||||
this.bankName,
|
||||
this.bankAccount,
|
||||
this.referenceNumber,
|
||||
this.notes,
|
||||
required this.status,
|
||||
this.receiptUrl,
|
||||
this.erpnextPaymentEntry,
|
||||
required this.createdAt,
|
||||
this.processedAt,
|
||||
});
|
||||
|
||||
factory PaymentLineModel.fromJson(Map<String, dynamic> json) => PaymentLineModel(
|
||||
paymentLineId: json['payment_line_id'] as String,
|
||||
invoiceId: json['invoice_id'] as String,
|
||||
paymentNumber: json['payment_number'] as String,
|
||||
paymentDate: DateTime.parse(json['payment_date']?.toString() ?? ''),
|
||||
amount: (json['amount'] as num).toDouble(),
|
||||
paymentMethod: PaymentMethod.values.firstWhere((e) => e.name == json['payment_method']),
|
||||
bankName: json['bank_name'] as String?,
|
||||
bankAccount: json['bank_account'] as String?,
|
||||
referenceNumber: json['reference_number'] as String?,
|
||||
notes: json['notes'] as String?,
|
||||
status: PaymentStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
receiptUrl: json['receipt_url'] as String?,
|
||||
erpnextPaymentEntry: json['erpnext_payment_entry'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
processedAt: json['processed_at'] != null ? DateTime.parse(json['processed_at']?.toString() ?? '') : null,
|
||||
);
|
||||
@HiveField(0)
|
||||
final String paymentLineId;
|
||||
@HiveField(1)
|
||||
final String invoiceId;
|
||||
@HiveField(2)
|
||||
final String paymentNumber;
|
||||
@HiveField(3)
|
||||
final DateTime paymentDate;
|
||||
@HiveField(4)
|
||||
final double amount;
|
||||
@HiveField(5)
|
||||
final PaymentMethod paymentMethod;
|
||||
@HiveField(6)
|
||||
final String? bankName;
|
||||
@HiveField(7)
|
||||
final String? bankAccount;
|
||||
@HiveField(8)
|
||||
final String? referenceNumber;
|
||||
@HiveField(9)
|
||||
final String? notes;
|
||||
@HiveField(10)
|
||||
final PaymentStatus status;
|
||||
@HiveField(11)
|
||||
final String? receiptUrl;
|
||||
@HiveField(12)
|
||||
final String? erpnextPaymentEntry;
|
||||
@HiveField(13)
|
||||
final DateTime createdAt;
|
||||
@HiveField(14)
|
||||
final DateTime? processedAt;
|
||||
|
||||
factory PaymentLineModel.fromJson(Map<String, dynamic> json) =>
|
||||
PaymentLineModel(
|
||||
paymentLineId: json['payment_line_id'] as String,
|
||||
invoiceId: json['invoice_id'] as String,
|
||||
paymentNumber: json['payment_number'] as String,
|
||||
paymentDate: DateTime.parse(json['payment_date']?.toString() ?? ''),
|
||||
amount: (json['amount'] as num).toDouble(),
|
||||
paymentMethod: PaymentMethod.values.firstWhere(
|
||||
(e) => e.name == json['payment_method'],
|
||||
),
|
||||
bankName: json['bank_name'] as String?,
|
||||
bankAccount: json['bank_account'] as String?,
|
||||
referenceNumber: json['reference_number'] as String?,
|
||||
notes: json['notes'] as String?,
|
||||
status: PaymentStatus.values.firstWhere(
|
||||
(e) => e.name == json['status'],
|
||||
),
|
||||
receiptUrl: json['receipt_url'] as String?,
|
||||
erpnextPaymentEntry: json['erpnext_payment_entry'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
processedAt: json['processed_at'] != null
|
||||
? DateTime.parse(json['processed_at']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'payment_line_id': paymentLineId,
|
||||
|
||||
@@ -15,7 +15,7 @@ enum InvoiceType {
|
||||
creditNote,
|
||||
|
||||
/// Debit note
|
||||
debitNote;
|
||||
debitNote,
|
||||
}
|
||||
|
||||
/// Invoice status enum
|
||||
@@ -166,8 +166,7 @@ class Invoice {
|
||||
(!isPaid && DateTime.now().isAfter(dueDate));
|
||||
|
||||
/// Check if invoice is partially paid
|
||||
bool get isPartiallyPaid =>
|
||||
amountPaid > 0 && amountPaid < totalAmount;
|
||||
bool get isPartiallyPaid => amountPaid > 0 && amountPaid < totalAmount;
|
||||
|
||||
/// Get payment percentage
|
||||
double get paymentPercentage {
|
||||
|
||||
@@ -50,8 +50,7 @@ class OrderItem {
|
||||
double get subtotalBeforeDiscount => quantity * unitPrice;
|
||||
|
||||
/// Calculate discount amount
|
||||
double get discountAmount =>
|
||||
subtotalBeforeDiscount * (discountPercent / 100);
|
||||
double get discountAmount => subtotalBeforeDiscount * (discountPercent / 100);
|
||||
|
||||
/// Calculate subtotal after discount (for verification)
|
||||
double get calculatedSubtotal => subtotalBeforeDiscount - discountAmount;
|
||||
|
||||
@@ -21,11 +21,7 @@ import 'package:worker/core/theme/colors.dart';
|
||||
/// - Order summary
|
||||
/// - Action buttons (Contact customer, Update status)
|
||||
class OrderDetailPage extends ConsumerWidget {
|
||||
|
||||
const OrderDetailPage({
|
||||
required this.orderId,
|
||||
super.key,
|
||||
});
|
||||
const OrderDetailPage({required this.orderId, super.key});
|
||||
final String orderId;
|
||||
|
||||
@override
|
||||
@@ -51,7 +47,9 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
onPressed: () {
|
||||
// TODO: Implement share functionality
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Chức năng chia sẻ đang phát triển')),
|
||||
const SnackBar(
|
||||
content: Text('Chức năng chia sẻ đang phát triển'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -142,14 +140,19 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
onPressed: () {
|
||||
// TODO: Implement contact customer
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Gọi điện cho khách hàng...')),
|
||||
const SnackBar(
|
||||
content: Text('Gọi điện cho khách hàng...'),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.phone),
|
||||
label: const Text('Liên hệ khách hàng'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: const BorderSide(color: AppColors.grey100, width: 2),
|
||||
side: const BorderSide(
|
||||
color: AppColors.grey100,
|
||||
width: 2,
|
||||
),
|
||||
foregroundColor: AppColors.grey900,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -162,7 +165,9 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
onPressed: () {
|
||||
// TODO: Implement update status
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cập nhật trạng thái...')),
|
||||
const SnackBar(
|
||||
content: Text('Cập nhật trạng thái...'),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.edit),
|
||||
@@ -196,9 +201,7 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -282,11 +285,7 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
color: iconBgColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
size: 12,
|
||||
color: iconColor,
|
||||
),
|
||||
child: Icon(iconData, size: 12, color: iconColor),
|
||||
),
|
||||
if (!isLast)
|
||||
Container(
|
||||
@@ -426,9 +425,7 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -436,7 +433,11 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.local_shipping, color: AppColors.primaryBlue, size: 20),
|
||||
const Icon(
|
||||
Icons.local_shipping,
|
||||
color: AppColors.primaryBlue,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Thông tin giao hàng',
|
||||
@@ -576,7 +577,9 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: valueColor != null ? FontWeight.w600 : FontWeight.w500,
|
||||
fontWeight: valueColor != null
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: valueColor ?? AppColors.grey900,
|
||||
),
|
||||
),
|
||||
@@ -595,9 +598,7 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -605,7 +606,11 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.person_outline, color: AppColors.primaryBlue, size: 20),
|
||||
const Icon(
|
||||
Icons.person_outline,
|
||||
color: AppColors.primaryBlue,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Thông tin khách hàng',
|
||||
@@ -634,13 +639,13 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
children: [
|
||||
const Text(
|
||||
'Loại khách hàng:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFFFD700), Color(0xFFFFA500)],
|
||||
@@ -673,10 +678,7 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
@@ -714,9 +716,7 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -724,7 +724,11 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.inventory_2, color: AppColors.primaryBlue, size: 20),
|
||||
const Icon(
|
||||
Icons.inventory_2,
|
||||
color: AppColors.primaryBlue,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Sản phẩm đặt hàng',
|
||||
@@ -739,113 +743,115 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
...products.map((product) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.grey100),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Product Image
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey50,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
...products.map(
|
||||
(product) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.grey100),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Product Image
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey50,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.image,
|
||||
color: AppColors.grey500,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.image,
|
||||
color: AppColors.grey500,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Product Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product['name']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Kích thước: ${product['size']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'SKU: ${product['sku']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Số lượng:',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
product['quantity']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Product Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product['name']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
product['unitPrice']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
product['totalPrice']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Kích thước: ${product['size']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'SKU: ${product['sku']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Số lượng:',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
product['quantity']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
product['unitPrice']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
product['totalPrice']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -870,9 +876,7 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -880,7 +884,11 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.receipt_long, color: AppColors.primaryBlue, size: 20),
|
||||
const Icon(
|
||||
Icons.receipt_long,
|
||||
color: AppColors.primaryBlue,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Tổng kết đơn hàng',
|
||||
@@ -900,7 +908,9 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
|
||||
_buildSummaryRow(
|
||||
'Phí vận chuyển:',
|
||||
shippingFee == 0 ? 'Miễn phí' : currencyFormatter.format(shippingFee),
|
||||
shippingFee == 0
|
||||
? 'Miễn phí'
|
||||
: currencyFormatter.format(shippingFee),
|
||||
valueColor: shippingFee == 0 ? AppColors.success : null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -924,14 +934,15 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
// Payment Method
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.credit_card, size: 16, color: AppColors.grey500),
|
||||
const Icon(
|
||||
Icons.credit_card,
|
||||
size: 16,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'Phương thức thanh toán:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -955,10 +966,7 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'Ghi chú đơn hàng:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -980,7 +988,12 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
/// Build Summary Row
|
||||
Widget _buildSummaryRow(String label, String value, {bool isTotal = false, Color? valueColor}) {
|
||||
Widget _buildSummaryRow(
|
||||
String label,
|
||||
String value, {
|
||||
bool isTotal = false,
|
||||
Color? valueColor,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -997,7 +1010,8 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: isTotal ? 16 : 14,
|
||||
fontWeight: isTotal ? FontWeight.w700 : FontWeight.w500,
|
||||
color: valueColor ?? (isTotal ? AppColors.danger : AppColors.grey900),
|
||||
color:
|
||||
valueColor ?? (isTotal ? AppColors.danger : AppColors.grey900),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1039,7 +1053,8 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
'deliveryMethod': 'Giao hàng tiêu chuẩn',
|
||||
'warehouseDate': DateTime(2023, 8, 5),
|
||||
'deliveryDate': DateTime(2023, 8, 7),
|
||||
'deliveryAddress': '123 Đường Lê Văn Lương, Phường Tân Hưng,\nQuận 7, TP. Hồ Chí Minh',
|
||||
'deliveryAddress':
|
||||
'123 Đường Lê Văn Lương, Phường Tân Hưng,\nQuận 7, TP. Hồ Chí Minh',
|
||||
'receiverName': 'Nguyễn Văn A',
|
||||
'receiverPhone': '0901234567',
|
||||
'customerName': 'Nguyễn Văn A',
|
||||
@@ -1051,7 +1066,8 @@ class OrderDetailPage extends ConsumerWidget {
|
||||
'discount': 129000.0,
|
||||
'total': 12771000.0,
|
||||
'paymentMethod': 'Chuyển khoản ngân hàng',
|
||||
'notes': 'Giao hàng trong giờ hành chính. Vui lòng gọi trước 30 phút khi đến giao hàng.',
|
||||
'notes':
|
||||
'Giao hàng trong giờ hành chính. Vui lòng gọi trước 30 phút khi đến giao hàng.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +43,9 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
ref.read(orderSearchQueryProvider.notifier).updateQuery(
|
||||
_searchController.text,
|
||||
);
|
||||
ref
|
||||
.read(orderSearchQueryProvider.notifier)
|
||||
.updateQuery(_searchController.text);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -68,9 +68,7 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
|
||||
backgroundColor: AppColors.white,
|
||||
foregroundColor: AppColors.grey900,
|
||||
centerTitle: false,
|
||||
actions: const [
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
actions: const [SizedBox(width: AppSpacing.sm)],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
@@ -87,9 +85,7 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
|
||||
),
|
||||
|
||||
// Filter Pills
|
||||
SliverToBoxAdapter(
|
||||
child: _buildFilterPills(selectedStatus),
|
||||
),
|
||||
SliverToBoxAdapter(child: _buildFilterPills(selectedStatus)),
|
||||
|
||||
// Orders List
|
||||
SliverPadding(
|
||||
@@ -101,18 +97,15 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final order = orders[index];
|
||||
return OrderCard(
|
||||
order: order,
|
||||
onTap: () {
|
||||
context.push('/orders/${order.orderId}');
|
||||
},
|
||||
);
|
||||
},
|
||||
childCount: orders.length,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final order = orders[index];
|
||||
return OrderCard(
|
||||
order: order,
|
||||
onTap: () {
|
||||
context.push('/orders/${order.orderId}');
|
||||
},
|
||||
);
|
||||
}, childCount: orders.length),
|
||||
);
|
||||
},
|
||||
loading: () => _buildLoadingState(),
|
||||
@@ -143,10 +136,7 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Mã đơn hàng',
|
||||
hintStyle: const TextStyle(
|
||||
color: AppColors.grey500,
|
||||
fontSize: 14,
|
||||
),
|
||||
hintStyle: const TextStyle(color: AppColors.grey500, fontSize: 14),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: AppColors.grey500,
|
||||
@@ -310,10 +300,7 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Thử tìm kiếm với từ khóa khác',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -324,9 +311,7 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
|
||||
/// Build loading state
|
||||
Widget _buildLoadingState() {
|
||||
return const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,10 +339,7 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -21,11 +21,8 @@ import 'package:worker/features/orders/presentation/providers/invoices_provider.
|
||||
/// - Payment history
|
||||
/// - Action buttons
|
||||
class PaymentDetailPage extends ConsumerWidget {
|
||||
const PaymentDetailPage({required this.invoiceId, super.key});
|
||||
|
||||
const PaymentDetailPage({
|
||||
required this.invoiceId,
|
||||
super.key,
|
||||
});
|
||||
/// Invoice ID
|
||||
final String invoiceId;
|
||||
|
||||
@@ -53,9 +50,9 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
icon: const Icon(Icons.share, color: Colors.black),
|
||||
onPressed: () {
|
||||
// TODO: Implement share functionality
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Chia sẻ hóa đơn')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Chia sẻ hóa đơn')));
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -134,8 +131,14 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
icon: const Icon(Icons.chat_bubble_outline),
|
||||
label: const Text('Liên hệ hỗ trợ'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||
side: const BorderSide(color: AppColors.grey100, width: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: 16,
|
||||
),
|
||||
side: const BorderSide(
|
||||
color: AppColors.grey100,
|
||||
width: 2,
|
||||
),
|
||||
foregroundColor: AppColors.grey900,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -151,12 +154,15 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: (invoice.status == InvoiceStatus.paid || invoice.isPaid)
|
||||
onPressed:
|
||||
(invoice.status == InvoiceStatus.paid || invoice.isPaid)
|
||||
? null
|
||||
: () {
|
||||
// TODO: Navigate to payment page
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Mở cổng thanh toán')),
|
||||
const SnackBar(
|
||||
content: Text('Mở cổng thanh toán'),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
@@ -171,7 +177,9 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: (invoice.status == InvoiceStatus.paid || invoice.isPaid)
|
||||
backgroundColor:
|
||||
(invoice.status == InvoiceStatus.paid ||
|
||||
invoice.isPaid)
|
||||
? AppColors.success
|
||||
: AppColors.primaryBlue,
|
||||
disabledBackgroundColor: AppColors.success,
|
||||
@@ -195,11 +203,18 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 64, color: AppColors.danger),
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Không tìm thấy hóa đơn',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
@@ -287,7 +302,9 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
'Còn lại:',
|
||||
currencyFormatter.format(amountRemaining),
|
||||
isHighlighted: true,
|
||||
valueColor: amountRemaining > 0 ? AppColors.danger : AppColors.success,
|
||||
valueColor: amountRemaining > 0
|
||||
? AppColors.danger
|
||||
: AppColors.success,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -354,7 +371,9 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isOverdue ? AppColors.danger : AppColors.grey900,
|
||||
color: isOverdue
|
||||
? AppColors.danger
|
||||
: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -451,79 +470,84 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
...products.map((product) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.grey100),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Product image placeholder
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
...products
|
||||
.map(
|
||||
(product) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.grey100),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Product image placeholder
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.image,
|
||||
color: AppColors.grey500,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.image,
|
||||
color: AppColors.grey500,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Product info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product['name']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'SKU: ${product['sku']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Số lượng: ${product['quantity']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Product info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product['name']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
Text(
|
||||
product['price']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'SKU: ${product['sku']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Số lượng: ${product['quantity']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
product['price']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
)
|
||||
.toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -27,11 +27,7 @@ class PaymentQrPage extends HookConsumerWidget {
|
||||
final String orderId;
|
||||
final double amount;
|
||||
|
||||
const PaymentQrPage({
|
||||
super.key,
|
||||
required this.orderId,
|
||||
required this.amount,
|
||||
});
|
||||
const PaymentQrPage({super.key, required this.orderId, required this.amount});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -186,7 +182,8 @@ class PaymentQrPage extends HookConsumerWidget {
|
||||
Widget _buildQrCodeCard(double amount, String orderId) {
|
||||
// Generate QR code data URL
|
||||
final qrData = Uri.encodeComponent(
|
||||
'https://eurotile.com/payment/$orderId?amount=$amount');
|
||||
'https://eurotile.com/payment/$orderId?amount=$amount',
|
||||
);
|
||||
final qrUrl =
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=$qrData';
|
||||
|
||||
@@ -283,11 +280,7 @@ class PaymentQrPage extends HookConsumerWidget {
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Bank Name
|
||||
_buildInfoRow(
|
||||
context: context,
|
||||
label: 'Ngân hàng:',
|
||||
value: 'BIDV',
|
||||
),
|
||||
_buildInfoRow(context: context, label: 'Ngân hàng:', value: 'BIDV'),
|
||||
|
||||
const Divider(height: 24),
|
||||
|
||||
@@ -329,8 +322,11 @@ class PaymentQrPage extends HookConsumerWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.lightbulb_outline,
|
||||
color: AppColors.primaryBlue, size: 20),
|
||||
const Icon(
|
||||
Icons.lightbulb_outline,
|
||||
color: AppColors.primaryBlue,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
@@ -414,7 +410,10 @@ class PaymentQrPage extends HookConsumerWidget {
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryBlue,
|
||||
side: const BorderSide(color: AppColors.primaryBlue, width: 1.5),
|
||||
side: const BorderSide(
|
||||
color: AppColors.primaryBlue,
|
||||
width: 1.5,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
@@ -534,9 +533,7 @@ class PaymentQrPage extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('• ', style: TextStyle(fontSize: 14)),
|
||||
Expanded(
|
||||
child: Text(text, style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 14))),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -597,9 +594,6 @@ class PaymentQrPage extends HookConsumerWidget {
|
||||
|
||||
/// Format currency
|
||||
String _formatCurrency(double amount) {
|
||||
return '${amount.toStringAsFixed(0).replaceAllMapped(
|
||||
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]}.',
|
||||
)}₫';
|
||||
return '${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}₫';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,9 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController..removeListener(_onTabChanged)
|
||||
..dispose();
|
||||
_tabController
|
||||
..removeListener(_onTabChanged)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -57,26 +58,38 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
}
|
||||
|
||||
/// Filter invoices based on tab key
|
||||
List<InvoiceModel> _filterInvoices(List<InvoiceModel> invoices, String tabKey) {
|
||||
List<InvoiceModel> _filterInvoices(
|
||||
List<InvoiceModel> invoices,
|
||||
String tabKey,
|
||||
) {
|
||||
var filtered = List<InvoiceModel>.from(invoices);
|
||||
|
||||
switch (tabKey) {
|
||||
case 'unpaid':
|
||||
// Unpaid tab: issued status only
|
||||
filtered = filtered
|
||||
.where((invoice) => invoice.status == InvoiceStatus.issued && !invoice.isPaid)
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.issued && !invoice.isPaid,
|
||||
)
|
||||
.toList();
|
||||
break;
|
||||
case 'overdue':
|
||||
// Overdue tab: overdue status
|
||||
filtered = filtered
|
||||
.where((invoice) => invoice.status == InvoiceStatus.overdue || invoice.isOverdue)
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.overdue || invoice.isOverdue,
|
||||
)
|
||||
.toList();
|
||||
break;
|
||||
case 'paid':
|
||||
// Paid tab: paid status
|
||||
filtered = filtered
|
||||
.where((invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid)
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.paid || invoice.isPaid,
|
||||
)
|
||||
.toList();
|
||||
break;
|
||||
case 'all':
|
||||
@@ -96,13 +109,21 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
return {
|
||||
'all': invoices.length,
|
||||
'unpaid': invoices
|
||||
.where((invoice) => invoice.status == InvoiceStatus.issued && !invoice.isPaid)
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.issued && !invoice.isPaid,
|
||||
)
|
||||
.length,
|
||||
'overdue': invoices
|
||||
.where((invoice) => invoice.status == InvoiceStatus.overdue || invoice.isOverdue)
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.overdue || invoice.isOverdue,
|
||||
)
|
||||
.length,
|
||||
'paid': invoices
|
||||
.where((invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid)
|
||||
.where(
|
||||
(invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid,
|
||||
)
|
||||
.length,
|
||||
};
|
||||
}
|
||||
@@ -190,7 +211,10 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: _tabs.map((tab) {
|
||||
final filteredInvoices = _filterInvoices(allInvoices, tab['key']!);
|
||||
final filteredInvoices = _filterInvoices(
|
||||
allInvoices,
|
||||
tab['key']!,
|
||||
);
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -200,21 +224,25 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
sliver: filteredInvoices.isEmpty
|
||||
? _buildEmptyState(tab['label']!)
|
||||
: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final invoice = filteredInvoices[index];
|
||||
return InvoiceCard(
|
||||
invoice: invoice,
|
||||
onTap: () {
|
||||
context.push('/payments/${invoice.invoiceId}');
|
||||
},
|
||||
onPaymentTap: () {
|
||||
context.push('/payments/${invoice.invoiceId}');
|
||||
},
|
||||
);
|
||||
},
|
||||
childCount: filteredInvoices.length,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((
|
||||
context,
|
||||
index,
|
||||
) {
|
||||
final invoice = filteredInvoices[index];
|
||||
return InvoiceCard(
|
||||
invoice: invoice,
|
||||
onTap: () {
|
||||
context.push(
|
||||
'/payments/${invoice.invoiceId}',
|
||||
);
|
||||
},
|
||||
onPaymentTap: () {
|
||||
context.push(
|
||||
'/payments/${invoice.invoiceId}',
|
||||
);
|
||||
},
|
||||
);
|
||||
}, childCount: filteredInvoices.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -240,9 +268,7 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
foregroundColor: AppColors.grey900,
|
||||
centerTitle: false,
|
||||
),
|
||||
body: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stack) => Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
@@ -281,10 +307,7 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -339,10 +362,7 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Kéo xuống để làm mới',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -74,17 +74,27 @@ Future<List<InvoiceModel>> filteredInvoices(Ref ref) async {
|
||||
if (selectedStatus == 'unpaid') {
|
||||
// Unpaid tab: issued status only
|
||||
filtered = filtered
|
||||
.where((invoice) => invoice.status == InvoiceStatus.issued && !invoice.isPaid)
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.issued && !invoice.isPaid,
|
||||
)
|
||||
.toList();
|
||||
} else if (selectedStatus == 'overdue') {
|
||||
// Overdue tab: overdue status
|
||||
filtered = filtered
|
||||
.where((invoice) => invoice.status == InvoiceStatus.overdue || invoice.isOverdue)
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.overdue ||
|
||||
invoice.isOverdue,
|
||||
)
|
||||
.toList();
|
||||
} else if (selectedStatus == 'paid') {
|
||||
// Paid tab: paid status
|
||||
filtered = filtered
|
||||
.where((invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid)
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.paid || invoice.isPaid,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -115,17 +125,25 @@ Future<Map<String, int>> invoicesCountByStatus(Ref ref) async {
|
||||
|
||||
// Unpaid tab (issued status)
|
||||
counts['unpaid'] = invoices
|
||||
.where((invoice) => invoice.status == InvoiceStatus.issued && !invoice.isPaid)
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.issued && !invoice.isPaid,
|
||||
)
|
||||
.length;
|
||||
|
||||
// Overdue tab
|
||||
counts['overdue'] = invoices
|
||||
.where((invoice) => invoice.status == InvoiceStatus.overdue || invoice.isOverdue)
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.overdue || invoice.isOverdue,
|
||||
)
|
||||
.length;
|
||||
|
||||
// Paid tab
|
||||
counts['paid'] = invoices
|
||||
.where((invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid)
|
||||
.where(
|
||||
(invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid,
|
||||
)
|
||||
.length;
|
||||
|
||||
return counts;
|
||||
@@ -144,7 +162,10 @@ Future<double> totalInvoicesAmount(Ref ref) async {
|
||||
|
||||
return invoicesAsync.when(
|
||||
data: (invoices) {
|
||||
return invoices.fold<double>(0.0, (sum, invoice) => sum + invoice.totalAmount);
|
||||
return invoices.fold<double>(
|
||||
0.0,
|
||||
(sum, invoice) => sum + invoice.totalAmount,
|
||||
);
|
||||
},
|
||||
loading: () => 0.0,
|
||||
error: (error, stack) => 0.0,
|
||||
@@ -160,7 +181,10 @@ Future<double> totalUnpaidAmount(Ref ref) async {
|
||||
|
||||
return invoicesAsync.when(
|
||||
data: (invoices) {
|
||||
return invoices.fold<double>(0.0, (sum, invoice) => sum + invoice.amountRemaining);
|
||||
return invoices.fold<double>(
|
||||
0.0,
|
||||
(sum, invoice) => sum + invoice.amountRemaining,
|
||||
);
|
||||
},
|
||||
loading: () => 0.0,
|
||||
error: (error, stack) => 0.0,
|
||||
|
||||
@@ -102,9 +102,9 @@ Future<List<OrderModel>> filteredOrders(Ref ref) async {
|
||||
if (searchQuery.isNotEmpty) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(order) => order.orderNumber
|
||||
.toLowerCase()
|
||||
.contains(searchQuery.toLowerCase()),
|
||||
(order) => order.orderNumber.toLowerCase().contains(
|
||||
searchQuery.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@ class InvoiceCard extends StatelessWidget {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
@@ -89,16 +87,10 @@ class InvoiceCard extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Invoice dates
|
||||
_buildDetailRow(
|
||||
'Ngày hóa đơn:',
|
||||
_formatDate(invoice.issueDate),
|
||||
),
|
||||
_buildDetailRow('Ngày hóa đơn:', _formatDate(invoice.issueDate)),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
_buildDetailRow(
|
||||
'Hạn thanh toán:',
|
||||
_formatDate(invoice.dueDate),
|
||||
),
|
||||
_buildDetailRow('Hạn thanh toán:', _formatDate(invoice.dueDate)),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -161,10 +153,7 @@ class InvoiceCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
|
||||
Text(
|
||||
@@ -191,10 +180,7 @@ class InvoiceCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
@@ -211,10 +197,7 @@ class InvoiceCard extends StatelessWidget {
|
||||
/// Build status badge
|
||||
Widget _buildStatusBadge() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(invoice.status).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -250,9 +233,7 @@ class InvoiceCard extends StatelessWidget {
|
||||
foregroundColor: Colors.white,
|
||||
disabledForegroundColor: AppColors.grey500,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: Row(
|
||||
@@ -263,10 +244,7 @@ class InvoiceCard extends StatelessWidget {
|
||||
Icon(Icons.credit_card),
|
||||
Text(
|
||||
buttonText,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -22,11 +22,7 @@ class OrderCard extends StatelessWidget {
|
||||
/// Tap callback
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const OrderCard({
|
||||
required this.order,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
const OrderCard({required this.order, this.onTap, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -39,9 +35,7 @@ class OrderCard extends StatelessWidget {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
@@ -80,10 +74,7 @@ class OrderCard extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Order details
|
||||
_buildDetailRow(
|
||||
'Ngày đặt:',
|
||||
_formatDate(order.createdAt),
|
||||
),
|
||||
_buildDetailRow('Ngày đặt:', _formatDate(order.createdAt)),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
_buildDetailRow(
|
||||
@@ -94,10 +85,7 @@ class OrderCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
_buildDetailRow(
|
||||
'Địa chỉ:',
|
||||
_getShortAddress(),
|
||||
),
|
||||
_buildDetailRow('Địa chỉ:', _getShortAddress()),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Status badge
|
||||
@@ -116,19 +104,13 @@ class OrderCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.grey900),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -138,10 +120,7 @@ class OrderCard extends StatelessWidget {
|
||||
/// Build status badge
|
||||
Widget _buildStatusBadge() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(order.status).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
|
||||
@@ -74,12 +74,16 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
|
||||
{
|
||||
'product_id': 'prod_001',
|
||||
'name': 'Gạch Cát Tường 1200x1200',
|
||||
'description': 'Gạch men bóng kiếng cao cấp, chống trượt, độ bền cao. Phù hợp cho phòng khách, phòng ngủ.',
|
||||
'description':
|
||||
'Gạch men bóng kiếng cao cấp, chống trượt, độ bền cao. Phù hợp cho phòng khách, phòng ngủ.',
|
||||
'base_price': 450000.0,
|
||||
'unit': 'm²',
|
||||
'images': ['https://www.eurotile.vn/pictures/catalog/product/0-gachkholon/cat-tuong/CAT-S01G-1.jpg'],
|
||||
'images': [
|
||||
'https://www.eurotile.vn/pictures/catalog/product/0-gachkholon/cat-tuong/CAT-S01G-1.jpg',
|
||||
],
|
||||
'image_captions': {},
|
||||
'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
|
||||
'link_360':
|
||||
'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
|
||||
'specifications': {},
|
||||
'category': 'floor_tiles',
|
||||
'brand': 'Eurotile',
|
||||
@@ -92,12 +96,16 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
|
||||
{
|
||||
'product_id': 'prod_002',
|
||||
'name': 'Gạch granite nhập khẩu',
|
||||
'description': 'Gạch granite nhập khẩu Tây Ban Nha, vân đá tự nhiên, sang trọng. Kích thước 80x80cm.',
|
||||
'description':
|
||||
'Gạch granite nhập khẩu Tây Ban Nha, vân đá tự nhiên, sang trọng. Kích thước 80x80cm.',
|
||||
'base_price': 680000.0,
|
||||
'unit': 'm²',
|
||||
'images': ['https://images.unsplash.com/photo-1565193566173-7a0ee3dbe261?w=300&h=300&fit=crop'],
|
||||
'images': [
|
||||
'https://images.unsplash.com/photo-1565193566173-7a0ee3dbe261?w=300&h=300&fit=crop',
|
||||
],
|
||||
'image_captions': {},
|
||||
'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R&locale=en_US',
|
||||
'link_360':
|
||||
'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R&locale=en_US',
|
||||
'specifications': {},
|
||||
'category': 'floor_tiles',
|
||||
'brand': 'Vasta Stone',
|
||||
@@ -110,12 +118,16 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
|
||||
{
|
||||
'product_id': 'prod_003',
|
||||
'name': 'Gạch mosaic trang trí',
|
||||
'description': 'Gạch mosaic thủy tinh màu sắc đa dạng, tạo điểm nhấn cho không gian. Kích thước 30x30cm.',
|
||||
'description':
|
||||
'Gạch mosaic thủy tinh màu sắc đa dạng, tạo điểm nhấn cho không gian. Kích thước 30x30cm.',
|
||||
'base_price': 320000.0,
|
||||
'unit': 'm²',
|
||||
'images': ['https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=300&h=300&fit=crop'],
|
||||
'images': [
|
||||
'https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=300&h=300&fit=crop',
|
||||
],
|
||||
'image_captions': {},
|
||||
'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
|
||||
'link_360':
|
||||
'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
|
||||
'specifications': {},
|
||||
'category': 'decorative_tiles',
|
||||
'brand': 'Eurotile',
|
||||
@@ -128,12 +140,16 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
|
||||
{
|
||||
'product_id': 'prod_004',
|
||||
'name': 'Gạch 3D họa tiết',
|
||||
'description': 'Gạch 3D với họa tiết nổi độc đáo, tạo hiệu ứng thị giác ấn tượng cho tường phòng khách.',
|
||||
'description':
|
||||
'Gạch 3D với họa tiết nổi độc đáo, tạo hiệu ứng thị giác ấn tượng cho tường phòng khách.',
|
||||
'base_price': 750000.0,
|
||||
'unit': 'm²',
|
||||
'images': ['https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=300&h=300&fit=crop'],
|
||||
'images': [
|
||||
'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=300&h=300&fit=crop',
|
||||
],
|
||||
'image_captions': {},
|
||||
'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
|
||||
'link_360':
|
||||
'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
|
||||
'specifications': {},
|
||||
'category': 'wall_tiles',
|
||||
'brand': 'Vasta Stone',
|
||||
@@ -146,12 +162,16 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
|
||||
{
|
||||
'product_id': 'prod_005',
|
||||
'name': 'Gạch ceramic chống trượt',
|
||||
'description': 'Gạch ceramic chống trượt cấp độ R11, an toàn cho phòng tắm và ban công. Kích thước 40x40cm.',
|
||||
'description':
|
||||
'Gạch ceramic chống trượt cấp độ R11, an toàn cho phòng tắm và ban công. Kích thước 40x40cm.',
|
||||
'base_price': 380000.0,
|
||||
'unit': 'm²',
|
||||
'images': ['https://images.unsplash.com/photo-1615874694520-474822394e73?w=300&h=300&fit=crop'],
|
||||
'images': [
|
||||
'https://images.unsplash.com/photo-1615874694520-474822394e73?w=300&h=300&fit=crop',
|
||||
],
|
||||
'image_captions': {},
|
||||
'link_360': 'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
|
||||
'link_360':
|
||||
'https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US',
|
||||
'specifications': {},
|
||||
'category': 'outdoor_tiles',
|
||||
'brand': 'Eurotile',
|
||||
@@ -164,10 +184,13 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
|
||||
{
|
||||
'product_id': 'prod_006',
|
||||
'name': 'Gạch terrazzo đá mài',
|
||||
'description': 'Gạch terrazzo phong cách retro, đá mài hạt màu, độc đáo và bền đẹp theo thời gian.',
|
||||
'description':
|
||||
'Gạch terrazzo phong cách retro, đá mài hạt màu, độc đáo và bền đẹp theo thời gian.',
|
||||
'base_price': 890000.0,
|
||||
'unit': 'm²',
|
||||
'images': ['https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=300&h=300&fit=crop'],
|
||||
'images': [
|
||||
'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=300&h=300&fit=crop',
|
||||
],
|
||||
'image_captions': {},
|
||||
'link_360': null,
|
||||
'specifications': {},
|
||||
@@ -186,9 +209,7 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
|
||||
// Simulate network delay
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
return _productsJson
|
||||
.map((json) => ProductModel.fromJson(json))
|
||||
.toList();
|
||||
return _productsJson.map((json) => ProductModel.fromJson(json)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -245,8 +266,6 @@ class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
|
||||
// Simulate network delay
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
return _categoriesJson
|
||||
.map((json) => CategoryModel.fromJson(json))
|
||||
.toList();
|
||||
return _categoriesJson.map((json) => CategoryModel.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,11 +139,13 @@ class ProductModel extends HiveObject {
|
||||
'description': description,
|
||||
'base_price': basePrice,
|
||||
'images': images != null ? jsonDecode(images!) : null,
|
||||
'image_captions':
|
||||
imageCaptions != null ? jsonDecode(imageCaptions!) : null,
|
||||
'image_captions': imageCaptions != null
|
||||
? jsonDecode(imageCaptions!)
|
||||
: null,
|
||||
'link_360': link360,
|
||||
'specifications':
|
||||
specifications != null ? jsonDecode(specifications!) : null,
|
||||
'specifications': specifications != null
|
||||
? jsonDecode(specifications!)
|
||||
: null,
|
||||
'category': category,
|
||||
'brand': brand,
|
||||
'unit': unit,
|
||||
|
||||
@@ -16,9 +16,7 @@ import 'package:worker/features/products/domain/repositories/products_repository
|
||||
class ProductsRepositoryImpl implements ProductsRepository {
|
||||
final ProductsLocalDataSource localDataSource;
|
||||
|
||||
const ProductsRepositoryImpl({
|
||||
required this.localDataSource,
|
||||
});
|
||||
const ProductsRepositoryImpl({required this.localDataSource});
|
||||
|
||||
@override
|
||||
Future<List<Product>> getAllProducts() async {
|
||||
@@ -43,7 +41,9 @@ class ProductsRepositoryImpl implements ProductsRepository {
|
||||
@override
|
||||
Future<List<Product>> getProductsByCategory(String categoryId) async {
|
||||
try {
|
||||
final productModels = await localDataSource.getProductsByCategory(categoryId);
|
||||
final productModels = await localDataSource.getProductsByCategory(
|
||||
categoryId,
|
||||
);
|
||||
return productModels.map((model) => model.toEntity()).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to get products by category: $e');
|
||||
|
||||
@@ -26,10 +26,7 @@ import 'package:worker/features/products/presentation/widgets/product_detail/sti
|
||||
class ProductDetailPage extends ConsumerStatefulWidget {
|
||||
final String productId;
|
||||
|
||||
const ProductDetailPage({
|
||||
super.key,
|
||||
required this.productId,
|
||||
});
|
||||
const ProductDetailPage({super.key, required this.productId});
|
||||
|
||||
@override
|
||||
ConsumerState<ProductDetailPage> createState() => _ProductDetailPageState();
|
||||
@@ -70,9 +67,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isFavorite
|
||||
? 'Đã thêm vào yêu thích'
|
||||
: 'Đã xóa khỏi yêu thích',
|
||||
_isFavorite ? 'Đã thêm vào yêu thích' : 'Đã xóa khỏi yêu thích',
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
@@ -109,10 +104,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
// Title
|
||||
const Text(
|
||||
'Chia sẻ sản phẩm',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
@@ -144,9 +136,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
Navigator.pop(context);
|
||||
// TODO: Copy to clipboard
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Đã sao chép link sản phẩm!'),
|
||||
),
|
||||
const SnackBar(content: Text('Đã sao chép link sản phẩm!')),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -269,9 +259,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
child: CircularProgressIndicator(color: AppColors.primaryBlue),
|
||||
),
|
||||
error: (error, stack) => Center(
|
||||
child: Padding(
|
||||
@@ -287,10 +275,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
const Text(
|
||||
'Không thể tải thông tin sản phẩm',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
@@ -55,7 +55,10 @@ class ProductsPage extends ConsumerWidget {
|
||||
backgroundColor: AppColors.danger,
|
||||
textColor: AppColors.white,
|
||||
isLabelVisible: cartItemCount > 0,
|
||||
child: const Icon(Icons.shopping_cart_outlined, color: Colors.black),
|
||||
child: const Icon(
|
||||
Icons.shopping_cart_outlined,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
onPressed: () => context.push(RouteNames.cart),
|
||||
),
|
||||
@@ -74,9 +77,7 @@ class ProductsPage extends ConsumerWidget {
|
||||
data: (categories) => CategoryFilterChips(categories: categories),
|
||||
loading: () => const SizedBox(
|
||||
height: 48.0,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2.0),
|
||||
),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
|
||||
),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
@@ -115,7 +116,8 @@ class ProductsPage extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
loading: () => _buildLoadingState(),
|
||||
error: (error, stack) => _buildErrorState(context, l10n, error, ref),
|
||||
error: (error, stack) =>
|
||||
_buildErrorState(context, l10n, error, ref),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -146,10 +148,7 @@ class ProductsPage extends ConsumerWidget {
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
l10n.noResults,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -159,9 +158,7 @@ class ProductsPage extends ConsumerWidget {
|
||||
/// Build loading state
|
||||
Widget _buildLoadingState() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
child: CircularProgressIndicator(color: AppColors.primaryBlue),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,10 +192,7 @@ class ProductsPage extends ConsumerWidget {
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
@@ -17,10 +17,7 @@ import 'package:worker/features/products/presentation/providers/selected_categor
|
||||
class CategoryFilterChips extends ConsumerWidget {
|
||||
final List<Category> categories;
|
||||
|
||||
const CategoryFilterChips({
|
||||
super.key,
|
||||
required this.categories,
|
||||
});
|
||||
const CategoryFilterChips({super.key, required this.categories});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -32,7 +29,8 @@ class CategoryFilterChips extends ConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
itemCount: categories.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: AppSpacing.sm),
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
final isSelected = selectedCategory == category.id;
|
||||
@@ -49,7 +47,9 @@ class CategoryFilterChips extends ConsumerWidget {
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
ref.read(selectedCategoryProvider.notifier).updateCategory(category.id);
|
||||
ref
|
||||
.read(selectedCategoryProvider.notifier)
|
||||
.updateCategory(category.id);
|
||||
}
|
||||
},
|
||||
backgroundColor: AppColors.white,
|
||||
|
||||
@@ -70,9 +70,7 @@ class ProductCard extends ConsumerWidget {
|
||||
placeholder: (context, url) => Shimmer.fromColors(
|
||||
baseColor: AppColors.grey100,
|
||||
highlightColor: AppColors.grey50,
|
||||
child: Container(
|
||||
color: AppColors.grey100,
|
||||
),
|
||||
child: Container(color: AppColors.grey100),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: AppColors.grey100,
|
||||
@@ -182,8 +180,12 @@ class ProductCard extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
isFavorited ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFavorited ? AppColors.danger : AppColors.grey500,
|
||||
isFavorited
|
||||
? Icons.favorite
|
||||
: Icons.favorite_border,
|
||||
color: isFavorited
|
||||
? AppColors.danger
|
||||
: AppColors.grey500,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
@@ -244,7 +246,9 @@ class ProductCard extends ConsumerWidget {
|
||||
disabledForegroundColor: AppColors.grey500,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppRadius.button,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
@@ -272,7 +276,9 @@ class ProductCard extends ConsumerWidget {
|
||||
// For now, show a message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Đang phát triển tính năng xem 360°'),
|
||||
content: Text(
|
||||
'Đang phát triển tính năng xem 360°',
|
||||
),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -285,7 +291,9 @@ class ProductCard extends ConsumerWidget {
|
||||
),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppRadius.button,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
|
||||
@@ -20,10 +20,7 @@ import 'package:worker/features/products/domain/entities/product.dart';
|
||||
class ImageGallerySection extends StatefulWidget {
|
||||
final Product product;
|
||||
|
||||
const ImageGallerySection({
|
||||
super.key,
|
||||
required this.product,
|
||||
});
|
||||
const ImageGallerySection({super.key, required this.product});
|
||||
|
||||
@override
|
||||
State<ImageGallerySection> createState() => _ImageGallerySectionState();
|
||||
@@ -232,9 +229,8 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: images[index],
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: AppColors.grey100,
|
||||
),
|
||||
placeholder: (context, url) =>
|
||||
Container(color: AppColors.grey100),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: AppColors.grey100,
|
||||
child: const Icon(
|
||||
@@ -321,10 +317,7 @@ class _ImageLightboxState extends State<_ImageLightbox> {
|
||||
),
|
||||
title: Text(
|
||||
'${_currentIndex + 1} / ${widget.images.length}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
style: const TextStyle(color: AppColors.white, fontSize: 16),
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
@@ -414,18 +407,12 @@ class _ImageLightboxState extends State<_ImageLightbox> {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withAlpha(128),
|
||||
Colors.transparent,
|
||||
],
|
||||
colors: [Colors.black.withAlpha(128), Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.imageCaptions[widget.images[_currentIndex]] ?? '',
|
||||
style: const TextStyle(
|
||||
color: AppColors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
style: const TextStyle(color: AppColors.white, fontSize: 16),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -19,10 +19,7 @@ import 'package:worker/features/products/domain/entities/product.dart';
|
||||
class ProductInfoSection extends StatelessWidget {
|
||||
final Product product;
|
||||
|
||||
const ProductInfoSection({
|
||||
super.key,
|
||||
required this.product,
|
||||
});
|
||||
const ProductInfoSection({super.key, required this.product});
|
||||
|
||||
String _formatPrice(double price) {
|
||||
final formatter = NumberFormat('#,###', 'vi_VN');
|
||||
@@ -40,10 +37,7 @@ class ProductInfoSection extends StatelessWidget {
|
||||
// SKU
|
||||
Text(
|
||||
'SKU: ${product.erpnextItemCode ?? product.productId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.grey500),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
@@ -168,18 +162,11 @@ class _QuickInfoCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: AppColors.primaryBlue,
|
||||
size: 24,
|
||||
),
|
||||
Icon(icon, color: AppColors.primaryBlue, size: 24),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
|
||||
@@ -17,10 +17,7 @@ import 'package:worker/features/products/domain/entities/product.dart';
|
||||
class ProductTabsSection extends StatefulWidget {
|
||||
final Product product;
|
||||
|
||||
const ProductTabsSection({
|
||||
super.key,
|
||||
required this.product,
|
||||
});
|
||||
const ProductTabsSection({super.key, required this.product});
|
||||
|
||||
@override
|
||||
State<ProductTabsSection> createState() => _ProductTabsSectionState();
|
||||
@@ -52,10 +49,7 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Color(0xFFe0e0e0),
|
||||
width: 1,
|
||||
),
|
||||
bottom: BorderSide(color: Color(0xFFe0e0e0), width: 1),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
@@ -152,29 +146,31 @@ class _DescriptionTab extends StatelessWidget {
|
||||
'Màu sắc bền đẹp theo thời gian',
|
||||
'Dễ dàng vệ sinh và bảo trì',
|
||||
'Thân thiện với môi trường',
|
||||
].map((feature) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: AppColors.success,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
feature,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
].map(
|
||||
(feature) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: AppColors.success,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
feature,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
@@ -242,9 +238,7 @@ class _SpecificationsTab extends StatelessWidget {
|
||||
border: isLast
|
||||
? null
|
||||
: const Border(
|
||||
bottom: BorderSide(
|
||||
color: Color(0xFFe0e0e0),
|
||||
),
|
||||
bottom: BorderSide(color: Color(0xFFe0e0e0)),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -335,9 +329,7 @@ class _ReviewsTab extends StatelessWidget {
|
||||
children: List.generate(
|
||||
5,
|
||||
(index) => Icon(
|
||||
index < 4
|
||||
? Icons.star
|
||||
: Icons.star_half,
|
||||
index < 4 ? Icons.star : Icons.star_half,
|
||||
color: const Color(0xFFffc107),
|
||||
size: 18,
|
||||
),
|
||||
@@ -349,10 +341,7 @@ class _ReviewsTab extends StatelessWidget {
|
||||
// Review count
|
||||
const Text(
|
||||
'125 đánh giá',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -382,11 +371,7 @@ class _ReviewItem extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Color(0xFFe0e0e0),
|
||||
),
|
||||
),
|
||||
border: Border(bottom: BorderSide(color: Color(0xFFe0e0e0))),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -482,6 +467,7 @@ final _mockReviews = [
|
||||
'name': 'Trần Thị B',
|
||||
'date': '1 tháng trước',
|
||||
'rating': 4,
|
||||
'text': 'Gạch đẹp, vân gỗ rất chân thực. Giao hàng nhanh chóng và đóng gói cẩn thận.',
|
||||
'text':
|
||||
'Gạch đẹp, vân gỗ rất chân thực. Giao hàng nhanh chóng và đóng gói cẩn thận.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -37,10 +37,7 @@ class StickyActionBar extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: const Color(0xFFe0e0e0),
|
||||
width: 1,
|
||||
),
|
||||
top: BorderSide(color: const Color(0xFFe0e0e0), width: 1),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
@@ -58,9 +55,7 @@ class StickyActionBar extends StatelessWidget {
|
||||
// Quantity Controls
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: const Color(0xFFe0e0e0),
|
||||
),
|
||||
border: Border.all(color: const Color(0xFFe0e0e0)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -81,9 +76,7 @@ class StickyActionBar extends StatelessWidget {
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -101,10 +94,7 @@ class StickyActionBar extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Increase Button
|
||||
_QuantityButton(
|
||||
icon: Icons.add,
|
||||
onPressed: onIncrease,
|
||||
),
|
||||
_QuantityButton(icon: Icons.add, onPressed: onIncrease),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -151,10 +141,7 @@ class _QuantityButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _QuantityButton({
|
||||
required this.icon,
|
||||
this.onPressed,
|
||||
});
|
||||
const _QuantityButton({required this.icon, this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -104,9 +104,7 @@ class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: InputFieldSpecs.fontSize,
|
||||
),
|
||||
style: const TextStyle(fontSize: InputFieldSpecs.fontSize),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,51 +7,103 @@ part 'design_request_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.designRequestModel)
|
||||
class DesignRequestModel extends HiveObject {
|
||||
DesignRequestModel({required this.requestId, required this.userId, required this.projectName, required this.projectType, required this.area, required this.style, required this.budget, required this.currentSituation, required this.requirements, this.notes, this.attachments, required this.status, this.assignedDesigner, this.finalDesignLink, this.feedback, this.rating, this.estimatedCompletion, required this.createdAt, this.completedAt, this.updatedAt});
|
||||
|
||||
@HiveField(0) final String requestId;
|
||||
@HiveField(1) final String userId;
|
||||
@HiveField(2) final String projectName;
|
||||
@HiveField(3) final ProjectType projectType;
|
||||
@HiveField(4) final double area;
|
||||
@HiveField(5) final String style;
|
||||
@HiveField(6) final double budget;
|
||||
@HiveField(7) final String currentSituation;
|
||||
@HiveField(8) final String requirements;
|
||||
@HiveField(9) final String? notes;
|
||||
@HiveField(10) final String? attachments;
|
||||
@HiveField(11) final DesignStatus status;
|
||||
@HiveField(12) final String? assignedDesigner;
|
||||
@HiveField(13) final String? finalDesignLink;
|
||||
@HiveField(14) final String? feedback;
|
||||
@HiveField(15) final int? rating;
|
||||
@HiveField(16) final DateTime? estimatedCompletion;
|
||||
@HiveField(17) final DateTime createdAt;
|
||||
@HiveField(18) final DateTime? completedAt;
|
||||
@HiveField(19) final DateTime? updatedAt;
|
||||
DesignRequestModel({
|
||||
required this.requestId,
|
||||
required this.userId,
|
||||
required this.projectName,
|
||||
required this.projectType,
|
||||
required this.area,
|
||||
required this.style,
|
||||
required this.budget,
|
||||
required this.currentSituation,
|
||||
required this.requirements,
|
||||
this.notes,
|
||||
this.attachments,
|
||||
required this.status,
|
||||
this.assignedDesigner,
|
||||
this.finalDesignLink,
|
||||
this.feedback,
|
||||
this.rating,
|
||||
this.estimatedCompletion,
|
||||
required this.createdAt,
|
||||
this.completedAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory DesignRequestModel.fromJson(Map<String, dynamic> json) => DesignRequestModel(
|
||||
requestId: json['request_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
projectName: json['project_name'] as String,
|
||||
projectType: ProjectType.values.firstWhere((e) => e.name == json['project_type']),
|
||||
area: (json['area'] as num).toDouble(),
|
||||
style: json['style'] as String,
|
||||
budget: (json['budget'] as num).toDouble(),
|
||||
currentSituation: json['current_situation'] as String,
|
||||
requirements: json['requirements'] as String,
|
||||
notes: json['notes'] as String?,
|
||||
attachments: json['attachments'] != null ? jsonEncode(json['attachments']) : null,
|
||||
status: DesignStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
assignedDesigner: json['assigned_designer'] as String?,
|
||||
finalDesignLink: json['final_design_link'] as String?,
|
||||
feedback: json['feedback'] as String?,
|
||||
rating: json['rating'] as int?,
|
||||
estimatedCompletion: json['estimated_completion'] != null ? DateTime.parse(json['estimated_completion']?.toString() ?? '') : null,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
completedAt: json['completed_at'] != null ? DateTime.parse(json['completed_at']?.toString() ?? '') : null,
|
||||
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null,
|
||||
);
|
||||
@HiveField(0)
|
||||
final String requestId;
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
@HiveField(2)
|
||||
final String projectName;
|
||||
@HiveField(3)
|
||||
final ProjectType projectType;
|
||||
@HiveField(4)
|
||||
final double area;
|
||||
@HiveField(5)
|
||||
final String style;
|
||||
@HiveField(6)
|
||||
final double budget;
|
||||
@HiveField(7)
|
||||
final String currentSituation;
|
||||
@HiveField(8)
|
||||
final String requirements;
|
||||
@HiveField(9)
|
||||
final String? notes;
|
||||
@HiveField(10)
|
||||
final String? attachments;
|
||||
@HiveField(11)
|
||||
final DesignStatus status;
|
||||
@HiveField(12)
|
||||
final String? assignedDesigner;
|
||||
@HiveField(13)
|
||||
final String? finalDesignLink;
|
||||
@HiveField(14)
|
||||
final String? feedback;
|
||||
@HiveField(15)
|
||||
final int? rating;
|
||||
@HiveField(16)
|
||||
final DateTime? estimatedCompletion;
|
||||
@HiveField(17)
|
||||
final DateTime createdAt;
|
||||
@HiveField(18)
|
||||
final DateTime? completedAt;
|
||||
@HiveField(19)
|
||||
final DateTime? updatedAt;
|
||||
|
||||
factory DesignRequestModel.fromJson(Map<String, dynamic> json) =>
|
||||
DesignRequestModel(
|
||||
requestId: json['request_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
projectName: json['project_name'] as String,
|
||||
projectType: ProjectType.values.firstWhere(
|
||||
(e) => e.name == json['project_type'],
|
||||
),
|
||||
area: (json['area'] as num).toDouble(),
|
||||
style: json['style'] as String,
|
||||
budget: (json['budget'] as num).toDouble(),
|
||||
currentSituation: json['current_situation'] as String,
|
||||
requirements: json['requirements'] as String,
|
||||
notes: json['notes'] as String?,
|
||||
attachments: json['attachments'] != null
|
||||
? jsonEncode(json['attachments'])
|
||||
: null,
|
||||
status: DesignStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
assignedDesigner: json['assigned_designer'] as String?,
|
||||
finalDesignLink: json['final_design_link'] as String?,
|
||||
feedback: json['feedback'] as String?,
|
||||
rating: json['rating'] as int?,
|
||||
estimatedCompletion: json['estimated_completion'] != null
|
||||
? DateTime.parse(json['estimated_completion']?.toString() ?? '')
|
||||
: null,
|
||||
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||
completedAt: json['completed_at'] != null
|
||||
? DateTime.parse(json['completed_at']?.toString() ?? '')
|
||||
: null,
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at']?.toString() ?? '')
|
||||
: null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'request_id': requestId,
|
||||
|
||||
@@ -7,41 +7,84 @@ part 'project_submission_model.g.dart';
|
||||
|
||||
@HiveType(typeId: HiveTypeIds.projectSubmissionModel)
|
||||
class ProjectSubmissionModel extends HiveObject {
|
||||
ProjectSubmissionModel({required this.submissionId, required this.userId, required this.projectName, required this.projectAddress, required this.projectValue, required this.projectType, this.beforePhotos, this.afterPhotos, this.invoices, required this.status, this.reviewNotes, this.rejectionReason, this.pointsEarned, required this.submittedAt, this.reviewedAt, this.reviewedBy});
|
||||
|
||||
@HiveField(0) final String submissionId;
|
||||
@HiveField(1) final String userId;
|
||||
@HiveField(2) final String projectName;
|
||||
@HiveField(3) final String projectAddress;
|
||||
@HiveField(4) final double projectValue;
|
||||
@HiveField(5) final ProjectType projectType;
|
||||
@HiveField(6) final String? beforePhotos;
|
||||
@HiveField(7) final String? afterPhotos;
|
||||
@HiveField(8) final String? invoices;
|
||||
@HiveField(9) final SubmissionStatus status;
|
||||
@HiveField(10) final String? reviewNotes;
|
||||
@HiveField(11) final String? rejectionReason;
|
||||
@HiveField(12) final int? pointsEarned;
|
||||
@HiveField(13) final DateTime submittedAt;
|
||||
@HiveField(14) final DateTime? reviewedAt;
|
||||
@HiveField(15) final String? reviewedBy;
|
||||
ProjectSubmissionModel({
|
||||
required this.submissionId,
|
||||
required this.userId,
|
||||
required this.projectName,
|
||||
required this.projectAddress,
|
||||
required this.projectValue,
|
||||
required this.projectType,
|
||||
this.beforePhotos,
|
||||
this.afterPhotos,
|
||||
this.invoices,
|
||||
required this.status,
|
||||
this.reviewNotes,
|
||||
this.rejectionReason,
|
||||
this.pointsEarned,
|
||||
required this.submittedAt,
|
||||
this.reviewedAt,
|
||||
this.reviewedBy,
|
||||
});
|
||||
|
||||
factory ProjectSubmissionModel.fromJson(Map<String, dynamic> json) => ProjectSubmissionModel(
|
||||
@HiveField(0)
|
||||
final String submissionId;
|
||||
@HiveField(1)
|
||||
final String userId;
|
||||
@HiveField(2)
|
||||
final String projectName;
|
||||
@HiveField(3)
|
||||
final String projectAddress;
|
||||
@HiveField(4)
|
||||
final double projectValue;
|
||||
@HiveField(5)
|
||||
final ProjectType projectType;
|
||||
@HiveField(6)
|
||||
final String? beforePhotos;
|
||||
@HiveField(7)
|
||||
final String? afterPhotos;
|
||||
@HiveField(8)
|
||||
final String? invoices;
|
||||
@HiveField(9)
|
||||
final SubmissionStatus status;
|
||||
@HiveField(10)
|
||||
final String? reviewNotes;
|
||||
@HiveField(11)
|
||||
final String? rejectionReason;
|
||||
@HiveField(12)
|
||||
final int? pointsEarned;
|
||||
@HiveField(13)
|
||||
final DateTime submittedAt;
|
||||
@HiveField(14)
|
||||
final DateTime? reviewedAt;
|
||||
@HiveField(15)
|
||||
final String? reviewedBy;
|
||||
|
||||
factory ProjectSubmissionModel.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => ProjectSubmissionModel(
|
||||
submissionId: json['submission_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
projectName: json['project_name'] as String,
|
||||
projectAddress: json['project_address'] as String,
|
||||
projectValue: (json['project_value'] as num).toDouble(),
|
||||
projectType: ProjectType.values.firstWhere((e) => e.name == json['project_type']),
|
||||
beforePhotos: json['before_photos'] != null ? jsonEncode(json['before_photos']) : null,
|
||||
afterPhotos: json['after_photos'] != null ? jsonEncode(json['after_photos']) : null,
|
||||
projectType: ProjectType.values.firstWhere(
|
||||
(e) => e.name == json['project_type'],
|
||||
),
|
||||
beforePhotos: json['before_photos'] != null
|
||||
? jsonEncode(json['before_photos'])
|
||||
: null,
|
||||
afterPhotos: json['after_photos'] != null
|
||||
? jsonEncode(json['after_photos'])
|
||||
: null,
|
||||
invoices: json['invoices'] != null ? jsonEncode(json['invoices']) : null,
|
||||
status: SubmissionStatus.values.firstWhere((e) => e.name == json['status']),
|
||||
reviewNotes: json['review_notes'] as String?,
|
||||
rejectionReason: json['rejection_reason'] as String?,
|
||||
pointsEarned: json['points_earned'] as int?,
|
||||
submittedAt: DateTime.parse(json['submitted_at']?.toString() ?? ''),
|
||||
reviewedAt: json['reviewed_at'] != null ? DateTime.parse(json['reviewed_at']?.toString() ?? '') : null,
|
||||
reviewedAt: json['reviewed_at'] != null
|
||||
? DateTime.parse(json['reviewed_at']?.toString() ?? '')
|
||||
: null,
|
||||
reviewedBy: json['reviewed_by'] as String?,
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user