Merge branch 'dev' of https://github.com/Oloodi/krow-workforce into feature/session-persistence-424
This commit is contained in:
@@ -284,13 +284,38 @@ extension StaffNavigator on IModularNavigator {
|
||||
pushNamed(StaffPaths.faqs);
|
||||
}
|
||||
|
||||
/// Pushes the privacy and security settings page.
|
||||
// ==========================================================================
|
||||
// PRIVACY & SECURITY
|
||||
// ==========================================================================
|
||||
|
||||
/// Navigates to the privacy and security settings page.
|
||||
///
|
||||
/// Manage privacy preferences and security settings.
|
||||
void toPrivacy() {
|
||||
pushNamed(StaffPaths.privacy);
|
||||
/// Manage privacy preferences including:
|
||||
/// * Location sharing settings
|
||||
/// * View terms of service
|
||||
/// * View privacy policy
|
||||
void toPrivacySecurity() {
|
||||
pushNamed(StaffPaths.privacySecurity);
|
||||
}
|
||||
|
||||
/// Navigates to the Terms of Service page.
|
||||
///
|
||||
/// Display the full terms of service document in a dedicated page view.
|
||||
void toTermsOfService() {
|
||||
pushNamed(StaffPaths.termsOfService);
|
||||
}
|
||||
|
||||
/// Navigates to the Privacy Policy page.
|
||||
///
|
||||
/// Display the full privacy policy document in a dedicated page view.
|
||||
void toPrivacyPolicy() {
|
||||
pushNamed(StaffPaths.privacyPolicy);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// MESSAGING & COMMUNICATION
|
||||
// ==========================================================================
|
||||
|
||||
/// Pushes the messages page (placeholder).
|
||||
///
|
||||
/// Access internal messaging system.
|
||||
|
||||
@@ -203,10 +203,33 @@ class StaffPaths {
|
||||
static const String leaderboard = '/leaderboard';
|
||||
|
||||
/// FAQs - frequently asked questions.
|
||||
static const String faqs = '/faqs';
|
||||
///
|
||||
/// Access to frequently asked questions about the staff application.
|
||||
static const String faqs = '/worker-main/faqs/';
|
||||
|
||||
// ==========================================================================
|
||||
// PRIVACY & SECURITY
|
||||
// ==========================================================================
|
||||
|
||||
/// Privacy and security settings.
|
||||
static const String privacy = '/privacy';
|
||||
///
|
||||
/// Manage privacy preferences, location sharing, terms of service,
|
||||
/// and privacy policy.
|
||||
static const String privacySecurity = '/worker-main/privacy-security/';
|
||||
|
||||
/// Terms of Service page.
|
||||
///
|
||||
/// Display the full terms of service document.
|
||||
static const String termsOfService = '/worker-main/privacy-security/terms/';
|
||||
|
||||
/// Privacy Policy page.
|
||||
///
|
||||
/// Display the full privacy policy document.
|
||||
static const String privacyPolicy = '/worker-main/privacy-security/policy/';
|
||||
|
||||
// ==========================================================================
|
||||
// MESSAGING & COMMUNICATION (Placeholders)
|
||||
// ==========================================================================
|
||||
|
||||
/// Messages - internal messaging system (placeholder).
|
||||
static const String messages = '/messages';
|
||||
|
||||
@@ -521,7 +521,8 @@
|
||||
"compliance": "COMPLIANCE",
|
||||
"level_up": "LEVEL UP",
|
||||
"finance": "FINANCE",
|
||||
"support": "SUPPORT"
|
||||
"support": "SUPPORT",
|
||||
"settings": "SETTINGS"
|
||||
},
|
||||
"menu_items": {
|
||||
"personal_info": "Personal Info",
|
||||
@@ -543,7 +544,8 @@
|
||||
"timecard": "Timecard",
|
||||
"faqs": "FAQs",
|
||||
"privacy_security": "Privacy & Security",
|
||||
"messages": "Messages"
|
||||
"messages": "Messages",
|
||||
"language": "Language"
|
||||
},
|
||||
"bank_account_page": {
|
||||
"title": "Bank Account",
|
||||
@@ -1125,6 +1127,30 @@
|
||||
"service_unavailable": "Service is currently unavailable."
|
||||
}
|
||||
},
|
||||
"staff_privacy_security": {
|
||||
"title": "Privacy & Security",
|
||||
"privacy_section": "Privacy",
|
||||
"legal_section": "Legal",
|
||||
"profile_visibility": {
|
||||
"title": "Profile Visibility",
|
||||
"subtitle": "Let clients see your profile"
|
||||
},
|
||||
"terms_of_service": {
|
||||
"title": "Terms of Service"
|
||||
},
|
||||
"privacy_policy": {
|
||||
"title": "Privacy Policy"
|
||||
},
|
||||
"success": {
|
||||
"profile_visibility_updated": "Profile visibility updated successfully!"
|
||||
}
|
||||
},
|
||||
"staff_faqs": {
|
||||
"title": "FAQs",
|
||||
"search_placeholder": "Search questions...",
|
||||
"no_results": "No matching questions found",
|
||||
"contact_support": "Contact Support"
|
||||
},
|
||||
"success": {
|
||||
"hub": {
|
||||
"created": "Hub created successfully!",
|
||||
|
||||
@@ -521,7 +521,8 @@
|
||||
"compliance": "CUMPLIMIENTO",
|
||||
"level_up": "MEJORAR NIVEL",
|
||||
"finance": "FINANZAS",
|
||||
"support": "SOPORTE"
|
||||
"support": "SOPORTE",
|
||||
"settings": "AJUSTES"
|
||||
},
|
||||
"menu_items": {
|
||||
"personal_info": "Información Personal",
|
||||
@@ -543,7 +544,8 @@
|
||||
"timecard": "Tarjeta de Tiempo",
|
||||
"faqs": "Preguntas Frecuentes",
|
||||
"privacy_security": "Privacidad y Seguridad",
|
||||
"messages": "Mensajes"
|
||||
"messages": "Mensajes",
|
||||
"language": "Idioma"
|
||||
},
|
||||
"bank_account_page": {
|
||||
"title": "Cuenta Bancaria",
|
||||
@@ -1125,6 +1127,30 @@
|
||||
"service_unavailable": "El servicio no está disponible actualmente."
|
||||
}
|
||||
},
|
||||
"staff_privacy_security": {
|
||||
"title": "Privacidad y Seguridad",
|
||||
"privacy_section": "Privacidad",
|
||||
"legal_section": "Legal",
|
||||
"profile_visibility": {
|
||||
"title": "Visibilidad del Perfil",
|
||||
"subtitle": "Deja que los clientes vean tu perfil"
|
||||
},
|
||||
"terms_of_service": {
|
||||
"title": "Términos de Servicio"
|
||||
},
|
||||
"privacy_policy": {
|
||||
"title": "Política de Privacidad"
|
||||
},
|
||||
"success": {
|
||||
"profile_visibility_updated": "¡Visibilidad del perfil actualizada exitosamente!"
|
||||
}
|
||||
},
|
||||
"staff_faqs": {
|
||||
"title": "Preguntas Frecuentes",
|
||||
"search_placeholder": "Buscar preguntas...",
|
||||
"no_results": "No se encontraron preguntas coincidentes",
|
||||
"contact_support": "Contactar Soporte"
|
||||
},
|
||||
"success": {
|
||||
"hub": {
|
||||
"created": "¡Hub creado exitosamente!",
|
||||
|
||||
@@ -21,8 +21,8 @@ class UiColors {
|
||||
/// Foreground color on primary background (#F7FAFC)
|
||||
static const Color primaryForeground = Color(0xFFF7FAFC);
|
||||
|
||||
/// Inverse primary color (#9FABF1)
|
||||
static const Color primaryInverse = Color(0xFF9FABF1);
|
||||
/// Inverse primary color (#0A39DF)
|
||||
static const Color primaryInverse = Color.fromARGB(23, 10, 56, 223);
|
||||
|
||||
/// Secondary background color (#F1F3F5)
|
||||
static const Color secondary = Color(0xFFF1F3F5);
|
||||
|
||||
@@ -267,4 +267,7 @@ class UiIcons {
|
||||
|
||||
/// Chef hat icon for attire
|
||||
static const IconData chefHat = _IconLib.chefHat;
|
||||
|
||||
/// Help circle icon for FAQs
|
||||
static const IconData helpCircle = _IconLib.helpCircle;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,9 @@ class UiTheme {
|
||||
),
|
||||
maximumSize: const Size(double.infinity, 54),
|
||||
).copyWith(
|
||||
side: WidgetStateProperty.resolveWith<BorderSide?>((Set<WidgetState> states) {
|
||||
side: WidgetStateProperty.resolveWith<BorderSide?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.disabled)) {
|
||||
return const BorderSide(
|
||||
color: UiColors.borderPrimary,
|
||||
@@ -80,7 +82,9 @@ class UiTheme {
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) {
|
||||
overlayColor: WidgetStateProperty.resolveWith((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.hovered))
|
||||
return UiColors.buttonPrimaryHover;
|
||||
return null;
|
||||
@@ -239,7 +243,9 @@ class UiTheme {
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: UiColors.white,
|
||||
indicatorColor: UiColors.primaryInverse.withAlpha(51), // 20% of 255
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((Set<WidgetState> states) {
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return UiTypography.footnote2m.textPrimary;
|
||||
}
|
||||
@@ -249,13 +255,38 @@ class UiTheme {
|
||||
|
||||
// Switch Theme
|
||||
switchTheme: SwitchThemeData(
|
||||
trackColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) {
|
||||
trackColor: WidgetStateProperty.resolveWith<Color>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return UiColors.switchActive;
|
||||
return UiColors.primary.withAlpha(60);
|
||||
}
|
||||
return UiColors.switchInactive;
|
||||
}),
|
||||
thumbColor: const WidgetStatePropertyAll<Color>(UiColors.white),
|
||||
thumbColor: WidgetStateProperty.resolveWith<Color>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return UiColors.primary;
|
||||
}
|
||||
return UiColors.white;
|
||||
}),
|
||||
trackOutlineColor: WidgetStateProperty.resolveWith<Color?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return UiColors.primary;
|
||||
}
|
||||
return UiColors.transparent;
|
||||
}),
|
||||
trackOutlineWidth: WidgetStateProperty.resolveWith<double?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return 1.0;
|
||||
}
|
||||
return 0.0;
|
||||
}),
|
||||
),
|
||||
|
||||
// Checkbox Theme
|
||||
|
||||
@@ -15,8 +15,6 @@ dependencies:
|
||||
google_fonts: ^7.0.2
|
||||
lucide_icons: ^0.257.0
|
||||
font_awesome_flutter: ^10.7.0
|
||||
core_localization:
|
||||
path: ../core_localization
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -37,6 +37,10 @@ export 'src/adapters/shifts/break/break_adapter.dart';
|
||||
export 'src/entities/orders/order_type.dart';
|
||||
export 'src/entities/orders/one_time_order.dart';
|
||||
export 'src/entities/orders/one_time_order_position.dart';
|
||||
export 'src/entities/orders/recurring_order.dart';
|
||||
export 'src/entities/orders/recurring_order_position.dart';
|
||||
export 'src/entities/orders/permanent_order.dart';
|
||||
export 'src/entities/orders/permanent_order_position.dart';
|
||||
export 'src/entities/orders/order_item.dart';
|
||||
|
||||
// Skills & Certs
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'permanent_order_position.dart';
|
||||
|
||||
/// Represents a permanent staffing request spanning a date range.
|
||||
class PermanentOrder extends Equatable {
|
||||
const PermanentOrder({
|
||||
required this.startDate,
|
||||
required this.permanentDays,
|
||||
required this.location,
|
||||
required this.positions,
|
||||
this.hub,
|
||||
this.eventName,
|
||||
this.vendorId,
|
||||
this.roleRates = const <String, double>{},
|
||||
});
|
||||
|
||||
/// Start date for the permanent schedule.
|
||||
final DateTime startDate;
|
||||
|
||||
/// Days of the week to repeat on (e.g., ["SUN", "MON", ...]).
|
||||
final List<String> permanentDays;
|
||||
|
||||
/// The primary location where the work will take place.
|
||||
final String location;
|
||||
|
||||
/// The list of positions and headcounts required for this order.
|
||||
final List<PermanentOrderPosition> positions;
|
||||
|
||||
/// Selected hub details for this order.
|
||||
final PermanentOrderHubDetails? hub;
|
||||
|
||||
/// Optional order name.
|
||||
final String? eventName;
|
||||
|
||||
/// Selected vendor id for this order.
|
||||
final String? vendorId;
|
||||
|
||||
/// Role hourly rates keyed by role id.
|
||||
final Map<String, double> roleRates;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
startDate,
|
||||
permanentDays,
|
||||
location,
|
||||
positions,
|
||||
hub,
|
||||
eventName,
|
||||
vendorId,
|
||||
roleRates,
|
||||
];
|
||||
}
|
||||
|
||||
/// Minimal hub details used during permanent order creation.
|
||||
class PermanentOrderHubDetails extends Equatable {
|
||||
const PermanentOrderHubDetails({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.placeId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.city,
|
||||
this.state,
|
||||
this.street,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String address;
|
||||
final String? placeId;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? city;
|
||||
final String? state;
|
||||
final String? street;
|
||||
final String? country;
|
||||
final String? zipCode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a specific position requirement within a [PermanentOrder].
|
||||
class PermanentOrderPosition extends Equatable {
|
||||
const PermanentOrderPosition({
|
||||
required this.role,
|
||||
required this.count,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.lunchBreak = 'NO_BREAK',
|
||||
this.location,
|
||||
});
|
||||
|
||||
/// The job role or title required.
|
||||
final String role;
|
||||
|
||||
/// The number of workers required for this position.
|
||||
final int count;
|
||||
|
||||
/// The scheduled start time (e.g., "09:00 AM").
|
||||
final String startTime;
|
||||
|
||||
/// The scheduled end time (e.g., "05:00 PM").
|
||||
final String endTime;
|
||||
|
||||
/// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30).
|
||||
final String lunchBreak;
|
||||
|
||||
/// Optional specific location for this position, if different from the order's main location.
|
||||
final String? location;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
role,
|
||||
count,
|
||||
startTime,
|
||||
endTime,
|
||||
lunchBreak,
|
||||
location,
|
||||
];
|
||||
|
||||
/// Creates a copy of this position with the given fields replaced.
|
||||
PermanentOrderPosition copyWith({
|
||||
String? role,
|
||||
int? count,
|
||||
String? startTime,
|
||||
String? endTime,
|
||||
String? lunchBreak,
|
||||
String? location,
|
||||
}) {
|
||||
return PermanentOrderPosition(
|
||||
role: role ?? this.role,
|
||||
count: count ?? this.count,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
lunchBreak: lunchBreak ?? this.lunchBreak,
|
||||
location: location ?? this.location,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'recurring_order_position.dart';
|
||||
|
||||
/// Represents a recurring staffing request spanning a date range.
|
||||
class RecurringOrder extends Equatable {
|
||||
const RecurringOrder({
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.recurringDays,
|
||||
required this.location,
|
||||
required this.positions,
|
||||
this.hub,
|
||||
this.eventName,
|
||||
this.vendorId,
|
||||
this.roleRates = const <String, double>{},
|
||||
});
|
||||
|
||||
/// Start date for the recurring schedule.
|
||||
final DateTime startDate;
|
||||
|
||||
/// End date for the recurring schedule.
|
||||
final DateTime endDate;
|
||||
|
||||
/// Days of the week to repeat on (e.g., ["S", "M", ...]).
|
||||
final List<String> recurringDays;
|
||||
|
||||
/// The primary location where the work will take place.
|
||||
final String location;
|
||||
|
||||
/// The list of positions and headcounts required for this order.
|
||||
final List<RecurringOrderPosition> positions;
|
||||
|
||||
/// Selected hub details for this order.
|
||||
final RecurringOrderHubDetails? hub;
|
||||
|
||||
/// Optional order name.
|
||||
final String? eventName;
|
||||
|
||||
/// Selected vendor id for this order.
|
||||
final String? vendorId;
|
||||
|
||||
/// Role hourly rates keyed by role id.
|
||||
final Map<String, double> roleRates;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
startDate,
|
||||
endDate,
|
||||
recurringDays,
|
||||
location,
|
||||
positions,
|
||||
hub,
|
||||
eventName,
|
||||
vendorId,
|
||||
roleRates,
|
||||
];
|
||||
}
|
||||
|
||||
/// Minimal hub details used during recurring order creation.
|
||||
class RecurringOrderHubDetails extends Equatable {
|
||||
const RecurringOrderHubDetails({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.placeId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.city,
|
||||
this.state,
|
||||
this.street,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String address;
|
||||
final String? placeId;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? city;
|
||||
final String? state;
|
||||
final String? street;
|
||||
final String? country;
|
||||
final String? zipCode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a specific position requirement within a [RecurringOrder].
|
||||
class RecurringOrderPosition extends Equatable {
|
||||
const RecurringOrderPosition({
|
||||
required this.role,
|
||||
required this.count,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.lunchBreak = 'NO_BREAK',
|
||||
this.location,
|
||||
});
|
||||
|
||||
/// The job role or title required.
|
||||
final String role;
|
||||
|
||||
/// The number of workers required for this position.
|
||||
final int count;
|
||||
|
||||
/// The scheduled start time (e.g., "09:00 AM").
|
||||
final String startTime;
|
||||
|
||||
/// The scheduled end time (e.g., "05:00 PM").
|
||||
final String endTime;
|
||||
|
||||
/// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30).
|
||||
final String lunchBreak;
|
||||
|
||||
/// Optional specific location for this position, if different from the order's main location.
|
||||
final String? location;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
role,
|
||||
count,
|
||||
startTime,
|
||||
endTime,
|
||||
lunchBreak,
|
||||
location,
|
||||
];
|
||||
|
||||
/// Creates a copy of this position with the given fields replaced.
|
||||
RecurringOrderPosition copyWith({
|
||||
String? role,
|
||||
int? count,
|
||||
String? startTime,
|
||||
String? endTime,
|
||||
String? lunchBreak,
|
||||
String? location,
|
||||
}) {
|
||||
return RecurringOrderPosition(
|
||||
role: role ?? this.role,
|
||||
count: count ?? this.count,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
lunchBreak: lunchBreak ?? this.lunchBreak,
|
||||
location: location ?? this.location,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,14 @@ import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'data/repositories_impl/client_create_order_repository_impl.dart';
|
||||
import 'domain/repositories/client_create_order_repository_interface.dart';
|
||||
import 'domain/usecases/create_one_time_order_usecase.dart';
|
||||
import 'domain/usecases/create_permanent_order_usecase.dart';
|
||||
import 'domain/usecases/create_recurring_order_usecase.dart';
|
||||
import 'domain/usecases/create_rapid_order_usecase.dart';
|
||||
import 'domain/usecases/get_order_types_usecase.dart';
|
||||
import 'presentation/blocs/client_create_order_bloc.dart';
|
||||
import 'presentation/blocs/one_time_order_bloc.dart';
|
||||
import 'presentation/blocs/permanent_order_bloc.dart';
|
||||
import 'presentation/blocs/recurring_order_bloc.dart';
|
||||
import 'presentation/blocs/rapid_order_bloc.dart';
|
||||
import 'presentation/pages/create_order_page.dart';
|
||||
import 'presentation/pages/one_time_order_page.dart';
|
||||
@@ -33,12 +37,16 @@ class ClientCreateOrderModule extends Module {
|
||||
// UseCases
|
||||
i.addLazySingleton(GetOrderTypesUseCase.new);
|
||||
i.addLazySingleton(CreateOneTimeOrderUseCase.new);
|
||||
i.addLazySingleton(CreatePermanentOrderUseCase.new);
|
||||
i.addLazySingleton(CreateRecurringOrderUseCase.new);
|
||||
i.addLazySingleton(CreateRapidOrderUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.add<ClientCreateOrderBloc>(ClientCreateOrderBloc.new);
|
||||
i.add<RapidOrderBloc>(RapidOrderBloc.new);
|
||||
i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new);
|
||||
i.add<PermanentOrderBloc>(PermanentOrderBloc.new);
|
||||
i.add<RecurringOrderBloc>(RecurringOrderBloc.new);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -33,16 +33,21 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
|
||||
// titleKey: 'client_create_order.types.rapid',
|
||||
// descriptionKey: 'client_create_order.types.rapid_desc',
|
||||
// ),
|
||||
// domain.OrderType(
|
||||
// id: 'recurring',
|
||||
// titleKey: 'client_create_order.types.recurring',
|
||||
// descriptionKey: 'client_create_order.types.recurring_desc',
|
||||
// ),
|
||||
domain.OrderType(
|
||||
id: 'recurring',
|
||||
titleKey: 'client_create_order.types.recurring',
|
||||
descriptionKey: 'client_create_order.types.recurring_desc',
|
||||
),
|
||||
// domain.OrderType(
|
||||
// id: 'permanent',
|
||||
// titleKey: 'client_create_order.types.permanent',
|
||||
// descriptionKey: 'client_create_order.types.permanent_desc',
|
||||
// ),
|
||||
domain.OrderType(
|
||||
id: 'permanent',
|
||||
titleKey: 'client_create_order.types.permanent',
|
||||
descriptionKey: 'client_create_order.types.permanent_desc',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -100,7 +105,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
|
||||
.state(hub.state)
|
||||
.street(hub.street)
|
||||
.country(hub.country)
|
||||
.status(dc.ShiftStatus.PENDING)
|
||||
.status(dc.ShiftStatus.CONFIRMED)
|
||||
.workersNeeded(workersNeeded)
|
||||
.filled(0)
|
||||
.durationDays(1)
|
||||
@@ -139,6 +144,246 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> createRecurringOrder(domain.RecurringOrder order) async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final String? vendorId = order.vendorId;
|
||||
if (vendorId == null || vendorId.isEmpty) {
|
||||
throw Exception('Vendor is missing.');
|
||||
}
|
||||
final domain.RecurringOrderHubDetails? hub = order.hub;
|
||||
if (hub == null || hub.id.isEmpty) {
|
||||
throw Exception('Hub is missing.');
|
||||
}
|
||||
|
||||
final DateTime orderDateOnly = DateTime(
|
||||
order.startDate.year,
|
||||
order.startDate.month,
|
||||
order.startDate.day,
|
||||
);
|
||||
final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly);
|
||||
final fdc.Timestamp startTimestamp = orderTimestamp;
|
||||
final fdc.Timestamp endTimestamp = _service.toTimestamp(order.endDate);
|
||||
|
||||
final fdc.OperationResult<dc.CreateOrderData, dc.CreateOrderVariables> orderResult =
|
||||
await _service.connector
|
||||
.createOrder(
|
||||
businessId: businessId,
|
||||
orderType: dc.OrderType.RECURRING,
|
||||
teamHubId: hub.id,
|
||||
)
|
||||
.vendorId(vendorId)
|
||||
.eventName(order.eventName)
|
||||
.status(dc.OrderStatus.POSTED)
|
||||
.date(orderTimestamp)
|
||||
.startDate(startTimestamp)
|
||||
.endDate(endTimestamp)
|
||||
.recurringDays(order.recurringDays)
|
||||
.execute();
|
||||
|
||||
final String orderId = orderResult.data.order_insert.id;
|
||||
|
||||
// NOTE: Recurring orders are limited to 30 days of generated shifts.
|
||||
// Future shifts beyond 30 days should be created by a scheduled job.
|
||||
final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29));
|
||||
final DateTime effectiveEndDate =
|
||||
order.endDate.isAfter(maxEndDate) ? maxEndDate : order.endDate;
|
||||
|
||||
final Set<String> selectedDays = Set<String>.from(order.recurringDays);
|
||||
final int workersNeeded = order.positions.fold<int>(
|
||||
0,
|
||||
(int sum, domain.RecurringOrderPosition position) => sum + position.count,
|
||||
);
|
||||
final double shiftCost = _calculateRecurringShiftCost(order);
|
||||
|
||||
final List<String> shiftIds = <String>[];
|
||||
for (DateTime day = orderDateOnly;
|
||||
!day.isAfter(effectiveEndDate);
|
||||
day = day.add(const Duration(days: 1))) {
|
||||
final String dayLabel = _weekdayLabel(day);
|
||||
if (!selectedDays.contains(dayLabel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String shiftTitle = 'Shift ${_formatDate(day)}';
|
||||
final fdc.Timestamp dayTimestamp = _service.toTimestamp(
|
||||
DateTime(day.year, day.month, day.day),
|
||||
);
|
||||
|
||||
final fdc.OperationResult<dc.CreateShiftData, dc.CreateShiftVariables> shiftResult =
|
||||
await _service.connector
|
||||
.createShift(title: shiftTitle, orderId: orderId)
|
||||
.date(dayTimestamp)
|
||||
.location(hub.name)
|
||||
.locationAddress(hub.address)
|
||||
.latitude(hub.latitude)
|
||||
.longitude(hub.longitude)
|
||||
.placeId(hub.placeId)
|
||||
.city(hub.city)
|
||||
.state(hub.state)
|
||||
.street(hub.street)
|
||||
.country(hub.country)
|
||||
.status(dc.ShiftStatus.CONFIRMED)
|
||||
.workersNeeded(workersNeeded)
|
||||
.filled(0)
|
||||
.durationDays(1)
|
||||
.cost(shiftCost)
|
||||
.execute();
|
||||
|
||||
final String shiftId = shiftResult.data.shift_insert.id;
|
||||
shiftIds.add(shiftId);
|
||||
|
||||
for (final domain.RecurringOrderPosition position in order.positions) {
|
||||
final DateTime start = _parseTime(day, position.startTime);
|
||||
final DateTime end = _parseTime(day, position.endTime);
|
||||
final DateTime normalizedEnd =
|
||||
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
|
||||
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
|
||||
final double rate = order.roleRates[position.role] ?? 0;
|
||||
final double totalValue = rate * hours * position.count;
|
||||
|
||||
await _service.connector
|
||||
.createShiftRole(
|
||||
shiftId: shiftId,
|
||||
roleId: position.role,
|
||||
count: position.count,
|
||||
)
|
||||
.startTime(_service.toTimestamp(start))
|
||||
.endTime(_service.toTimestamp(normalizedEnd))
|
||||
.hours(hours)
|
||||
.breakType(_breakDurationFromValue(position.lunchBreak))
|
||||
.isBreakPaid(_isBreakPaid(position.lunchBreak))
|
||||
.totalValue(totalValue)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
await _service.connector
|
||||
.updateOrder(id: orderId, teamHubId: hub.id)
|
||||
.shifts(fdc.AnyValue(shiftIds))
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> createPermanentOrder(domain.PermanentOrder order) async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final String? vendorId = order.vendorId;
|
||||
if (vendorId == null || vendorId.isEmpty) {
|
||||
throw Exception('Vendor is missing.');
|
||||
}
|
||||
final domain.PermanentOrderHubDetails? hub = order.hub;
|
||||
if (hub == null || hub.id.isEmpty) {
|
||||
throw Exception('Hub is missing.');
|
||||
}
|
||||
|
||||
final DateTime orderDateOnly = DateTime(
|
||||
order.startDate.year,
|
||||
order.startDate.month,
|
||||
order.startDate.day,
|
||||
);
|
||||
final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly);
|
||||
final fdc.Timestamp startTimestamp = orderTimestamp;
|
||||
|
||||
final fdc.OperationResult<dc.CreateOrderData, dc.CreateOrderVariables> orderResult =
|
||||
await _service.connector
|
||||
.createOrder(
|
||||
businessId: businessId,
|
||||
orderType: dc.OrderType.PERMANENT,
|
||||
teamHubId: hub.id,
|
||||
)
|
||||
.vendorId(vendorId)
|
||||
.eventName(order.eventName)
|
||||
.status(dc.OrderStatus.POSTED)
|
||||
.date(orderTimestamp)
|
||||
.startDate(startTimestamp)
|
||||
.permanentDays(order.permanentDays)
|
||||
.execute();
|
||||
|
||||
final String orderId = orderResult.data.order_insert.id;
|
||||
|
||||
// NOTE: Permanent orders are limited to 30 days of generated shifts.
|
||||
// Future shifts beyond 30 days should be created by a scheduled job.
|
||||
final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29));
|
||||
|
||||
final Set<String> selectedDays = Set<String>.from(order.permanentDays);
|
||||
final int workersNeeded = order.positions.fold<int>(
|
||||
0,
|
||||
(int sum, domain.PermanentOrderPosition position) => sum + position.count,
|
||||
);
|
||||
final double shiftCost = _calculatePermanentShiftCost(order);
|
||||
|
||||
final List<String> shiftIds = <String>[];
|
||||
for (DateTime day = orderDateOnly;
|
||||
!day.isAfter(maxEndDate);
|
||||
day = day.add(const Duration(days: 1))) {
|
||||
final String dayLabel = _weekdayLabel(day);
|
||||
if (!selectedDays.contains(dayLabel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String shiftTitle = 'Shift ${_formatDate(day)}';
|
||||
final fdc.Timestamp dayTimestamp = _service.toTimestamp(
|
||||
DateTime(day.year, day.month, day.day),
|
||||
);
|
||||
|
||||
final fdc.OperationResult<dc.CreateShiftData, dc.CreateShiftVariables> shiftResult =
|
||||
await _service.connector
|
||||
.createShift(title: shiftTitle, orderId: orderId)
|
||||
.date(dayTimestamp)
|
||||
.location(hub.name)
|
||||
.locationAddress(hub.address)
|
||||
.latitude(hub.latitude)
|
||||
.longitude(hub.longitude)
|
||||
.placeId(hub.placeId)
|
||||
.city(hub.city)
|
||||
.state(hub.state)
|
||||
.street(hub.street)
|
||||
.country(hub.country)
|
||||
.status(dc.ShiftStatus.CONFIRMED)
|
||||
.workersNeeded(workersNeeded)
|
||||
.filled(0)
|
||||
.durationDays(1)
|
||||
.cost(shiftCost)
|
||||
.execute();
|
||||
|
||||
final String shiftId = shiftResult.data.shift_insert.id;
|
||||
shiftIds.add(shiftId);
|
||||
|
||||
for (final domain.PermanentOrderPosition position in order.positions) {
|
||||
final DateTime start = _parseTime(day, position.startTime);
|
||||
final DateTime end = _parseTime(day, position.endTime);
|
||||
final DateTime normalizedEnd =
|
||||
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
|
||||
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
|
||||
final double rate = order.roleRates[position.role] ?? 0;
|
||||
final double totalValue = rate * hours * position.count;
|
||||
|
||||
await _service.connector
|
||||
.createShiftRole(
|
||||
shiftId: shiftId,
|
||||
roleId: position.role,
|
||||
count: position.count,
|
||||
)
|
||||
.startTime(_service.toTimestamp(start))
|
||||
.endTime(_service.toTimestamp(normalizedEnd))
|
||||
.hours(hours)
|
||||
.breakType(_breakDurationFromValue(position.lunchBreak))
|
||||
.isBreakPaid(_isBreakPaid(position.lunchBreak))
|
||||
.totalValue(totalValue)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
await _service.connector
|
||||
.updateOrder(id: orderId, teamHubId: hub.id)
|
||||
.shifts(fdc.AnyValue(shiftIds))
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> createRapidOrder(String description) async {
|
||||
// TO-DO: connect IA and return array with the information.
|
||||
@@ -159,6 +404,54 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
|
||||
return total;
|
||||
}
|
||||
|
||||
double _calculateRecurringShiftCost(domain.RecurringOrder order) {
|
||||
double total = 0;
|
||||
for (final domain.RecurringOrderPosition position in order.positions) {
|
||||
final DateTime start = _parseTime(order.startDate, position.startTime);
|
||||
final DateTime end = _parseTime(order.startDate, position.endTime);
|
||||
final DateTime normalizedEnd =
|
||||
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
|
||||
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
|
||||
final double rate = order.roleRates[position.role] ?? 0;
|
||||
total += rate * hours * position.count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
double _calculatePermanentShiftCost(domain.PermanentOrder order) {
|
||||
double total = 0;
|
||||
for (final domain.PermanentOrderPosition position in order.positions) {
|
||||
final DateTime start = _parseTime(order.startDate, position.startTime);
|
||||
final DateTime end = _parseTime(order.startDate, position.endTime);
|
||||
final DateTime normalizedEnd =
|
||||
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
|
||||
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
|
||||
final double rate = order.roleRates[position.role] ?? 0;
|
||||
total += rate * hours * position.count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
String _weekdayLabel(DateTime date) {
|
||||
switch (date.weekday) {
|
||||
case DateTime.monday:
|
||||
return 'MON';
|
||||
case DateTime.tuesday:
|
||||
return 'TUE';
|
||||
case DateTime.wednesday:
|
||||
return 'WED';
|
||||
case DateTime.thursday:
|
||||
return 'THU';
|
||||
case DateTime.friday:
|
||||
return 'FRI';
|
||||
case DateTime.saturday:
|
||||
return 'SAT';
|
||||
case DateTime.sunday:
|
||||
default:
|
||||
return 'SUN';
|
||||
}
|
||||
}
|
||||
|
||||
dc.BreakDuration _breakDurationFromValue(String value) {
|
||||
switch (value) {
|
||||
case 'MIN_10':
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class PermanentOrderArguments {
|
||||
const PermanentOrderArguments({required this.order});
|
||||
final PermanentOrder order;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class RecurringOrderArguments {
|
||||
const RecurringOrderArguments({required this.order});
|
||||
final RecurringOrder order;
|
||||
}
|
||||
@@ -17,6 +17,12 @@ abstract interface class ClientCreateOrderRepositoryInterface {
|
||||
/// [order] contains the date, location, and required positions.
|
||||
Future<void> createOneTimeOrder(OneTimeOrder order);
|
||||
|
||||
/// Submits a recurring staffing order with specific details.
|
||||
Future<void> createRecurringOrder(RecurringOrder order);
|
||||
|
||||
/// Submits a permanent staffing order with specific details.
|
||||
Future<void> createPermanentOrder(PermanentOrder order);
|
||||
|
||||
/// Submits a rapid (urgent) staffing order via a text description.
|
||||
///
|
||||
/// [description] is the text message (or transcribed voice) describing the need.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../arguments/permanent_order_arguments.dart';
|
||||
import '../repositories/client_create_order_repository_interface.dart';
|
||||
|
||||
/// Use case for creating a permanent staffing order.
|
||||
class CreatePermanentOrderUseCase
|
||||
implements UseCase<PermanentOrderArguments, void> {
|
||||
/// Creates a [CreatePermanentOrderUseCase].
|
||||
const CreatePermanentOrderUseCase(this._repository);
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(PermanentOrderArguments input) {
|
||||
return _repository.createPermanentOrder(input.order);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../arguments/recurring_order_arguments.dart';
|
||||
import '../repositories/client_create_order_repository_interface.dart';
|
||||
|
||||
/// Use case for creating a recurring staffing order.
|
||||
class CreateRecurringOrderUseCase
|
||||
implements UseCase<RecurringOrderArguments, void> {
|
||||
/// Creates a [CreateRecurringOrderUseCase].
|
||||
const CreateRecurringOrderUseCase(this._repository);
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(RecurringOrderArguments input) {
|
||||
return _repository.createRecurringOrder(input.order);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
import '../../domain/arguments/permanent_order_arguments.dart';
|
||||
import '../../domain/usecases/create_permanent_order_usecase.dart';
|
||||
import 'permanent_order_event.dart';
|
||||
import 'permanent_order_state.dart';
|
||||
|
||||
/// BLoC for managing the permanent order creation form.
|
||||
class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
with BlocErrorHandler<PermanentOrderState>, SafeBloc<PermanentOrderEvent, PermanentOrderState> {
|
||||
PermanentOrderBloc(this._createPermanentOrderUseCase, this._service)
|
||||
: super(PermanentOrderState.initial()) {
|
||||
on<PermanentOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<PermanentOrderVendorChanged>(_onVendorChanged);
|
||||
on<PermanentOrderHubsLoaded>(_onHubsLoaded);
|
||||
on<PermanentOrderHubChanged>(_onHubChanged);
|
||||
on<PermanentOrderEventNameChanged>(_onEventNameChanged);
|
||||
on<PermanentOrderStartDateChanged>(_onStartDateChanged);
|
||||
on<PermanentOrderDayToggled>(_onDayToggled);
|
||||
on<PermanentOrderPositionAdded>(_onPositionAdded);
|
||||
on<PermanentOrderPositionRemoved>(_onPositionRemoved);
|
||||
on<PermanentOrderPositionUpdated>(_onPositionUpdated);
|
||||
on<PermanentOrderSubmitted>(_onSubmitted);
|
||||
|
||||
_loadVendors();
|
||||
_loadHubs();
|
||||
}
|
||||
|
||||
final CreatePermanentOrderUseCase _createPermanentOrderUseCase;
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
|
||||
Future<void> _loadVendors() async {
|
||||
final List<domain.Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final QueryResult<dc.ListVendorsData, void> result =
|
||||
await _service.connector.listVendors().execute();
|
||||
return result.data.vendors
|
||||
.map(
|
||||
(dc.ListVendorsVendors vendor) => domain.Vendor(
|
||||
id: vendor.id,
|
||||
name: vendor.companyName,
|
||||
rates: const <String, double>{},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
onError: (_) => add(const PermanentOrderVendorsLoaded(<domain.Vendor>[])),
|
||||
);
|
||||
|
||||
if (vendors != null) {
|
||||
add(PermanentOrderVendorsLoaded(vendors));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRolesForVendor(
|
||||
String vendorId,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) async {
|
||||
final List<PermanentOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final QueryResult<dc.ListRolesByVendorIdData, dc.ListRolesByVendorIdVariables>
|
||||
result = await _service.connector
|
||||
.listRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
return result.data.roles
|
||||
.map(
|
||||
(dc.ListRolesByVendorIdRoles role) => PermanentOrderRoleOption(
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
costPerHour: role.costPerHour,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
onError: (_) => emit(state.copyWith(roles: const <PermanentOrderRoleOption>[])),
|
||||
);
|
||||
|
||||
if (roles != null) {
|
||||
emit(state.copyWith(roles: roles));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadHubs() async {
|
||||
final List<PermanentOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final QueryResult<dc.ListTeamHubsByOwnerIdData, dc.ListTeamHubsByOwnerIdVariables>
|
||||
result = await _service.connector
|
||||
.listTeamHubsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
return result.data.teamHubs
|
||||
.map(
|
||||
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => PermanentOrderHubOption(
|
||||
id: hub.id,
|
||||
name: hub.hubName,
|
||||
address: hub.address,
|
||||
placeId: hub.placeId,
|
||||
latitude: hub.latitude,
|
||||
longitude: hub.longitude,
|
||||
city: hub.city,
|
||||
state: hub.state,
|
||||
street: hub.street,
|
||||
country: hub.country,
|
||||
zipCode: hub.zipCode,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
onError: (_) => add(const PermanentOrderHubsLoaded(<PermanentOrderHubOption>[])),
|
||||
);
|
||||
|
||||
if (hubs != null) {
|
||||
add(PermanentOrderHubsLoaded(hubs));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onVendorsLoaded(
|
||||
PermanentOrderVendorsLoaded event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) async {
|
||||
final domain.Vendor? selectedVendor =
|
||||
event.vendors.isNotEmpty ? event.vendors.first : null;
|
||||
emit(
|
||||
state.copyWith(
|
||||
vendors: event.vendors,
|
||||
selectedVendor: selectedVendor,
|
||||
),
|
||||
);
|
||||
if (selectedVendor != null) {
|
||||
await _loadRolesForVendor(selectedVendor.id, emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onVendorChanged(
|
||||
PermanentOrderVendorChanged event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(selectedVendor: event.vendor));
|
||||
await _loadRolesForVendor(event.vendor.id, emit);
|
||||
}
|
||||
|
||||
void _onHubsLoaded(
|
||||
PermanentOrderHubsLoaded event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
final PermanentOrderHubOption? selectedHub =
|
||||
event.hubs.isNotEmpty ? event.hubs.first : null;
|
||||
emit(
|
||||
state.copyWith(
|
||||
hubs: event.hubs,
|
||||
selectedHub: selectedHub,
|
||||
location: selectedHub?.name ?? '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onHubChanged(
|
||||
PermanentOrderHubChanged event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedHub: event.hub,
|
||||
location: event.hub.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onEventNameChanged(
|
||||
PermanentOrderEventNameChanged event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
emit(state.copyWith(eventName: event.eventName));
|
||||
}
|
||||
|
||||
void _onStartDateChanged(
|
||||
PermanentOrderStartDateChanged event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
final int newDayIndex = event.date.weekday % 7;
|
||||
final int? autoIndex = state.autoSelectedDayIndex;
|
||||
List<String> days = List<String>.from(state.permanentDays);
|
||||
if (autoIndex != null) {
|
||||
final String oldDay = _dayLabels[autoIndex];
|
||||
days.remove(oldDay);
|
||||
final String newDay = _dayLabels[newDayIndex];
|
||||
if (!days.contains(newDay)) {
|
||||
days.add(newDay);
|
||||
}
|
||||
days = _sortDays(days);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
startDate: event.date,
|
||||
permanentDays: days,
|
||||
autoSelectedDayIndex: autoIndex == null ? null : newDayIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onDayToggled(
|
||||
PermanentOrderDayToggled event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
final List<String> days = List<String>.from(state.permanentDays);
|
||||
final String label = _dayLabels[event.dayIndex];
|
||||
int? autoIndex = state.autoSelectedDayIndex;
|
||||
if (days.contains(label)) {
|
||||
days.remove(label);
|
||||
if (autoIndex == event.dayIndex) {
|
||||
autoIndex = null;
|
||||
}
|
||||
} else {
|
||||
days.add(label);
|
||||
}
|
||||
emit(state.copyWith(permanentDays: _sortDays(days), autoSelectedDayIndex: autoIndex));
|
||||
}
|
||||
|
||||
void _onPositionAdded(
|
||||
PermanentOrderPositionAdded event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
final List<PermanentOrderPosition> newPositions =
|
||||
List<PermanentOrderPosition>.from(state.positions)..add(
|
||||
const PermanentOrderPosition(
|
||||
role: '',
|
||||
count: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '17:00',
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(positions: newPositions));
|
||||
}
|
||||
|
||||
void _onPositionRemoved(
|
||||
PermanentOrderPositionRemoved event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
if (state.positions.length > 1) {
|
||||
final List<PermanentOrderPosition> newPositions =
|
||||
List<PermanentOrderPosition>.from(state.positions)
|
||||
..removeAt(event.index);
|
||||
emit(state.copyWith(positions: newPositions));
|
||||
}
|
||||
}
|
||||
|
||||
void _onPositionUpdated(
|
||||
PermanentOrderPositionUpdated event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) {
|
||||
final List<PermanentOrderPosition> newPositions =
|
||||
List<PermanentOrderPosition>.from(state.positions);
|
||||
newPositions[event.index] = event.position;
|
||||
emit(state.copyWith(positions: newPositions));
|
||||
}
|
||||
|
||||
Future<void> _onSubmitted(
|
||||
PermanentOrderSubmitted event,
|
||||
Emitter<PermanentOrderState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: PermanentOrderStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final Map<String, double> roleRates = <String, double>{
|
||||
for (final PermanentOrderRoleOption role in state.roles)
|
||||
role.id: role.costPerHour,
|
||||
};
|
||||
final PermanentOrderHubOption? selectedHub = state.selectedHub;
|
||||
if (selectedHub == null) {
|
||||
throw domain.OrderMissingHubException();
|
||||
}
|
||||
final domain.PermanentOrder order = domain.PermanentOrder(
|
||||
startDate: state.startDate,
|
||||
permanentDays: state.permanentDays,
|
||||
location: selectedHub.name,
|
||||
positions: state.positions
|
||||
.map(
|
||||
(PermanentOrderPosition p) => domain.PermanentOrderPosition(
|
||||
role: p.role,
|
||||
count: p.count,
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
lunchBreak: p.lunchBreak ?? 'NO_BREAK',
|
||||
location: null,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
hub: domain.PermanentOrderHubDetails(
|
||||
id: selectedHub.id,
|
||||
name: selectedHub.name,
|
||||
address: selectedHub.address,
|
||||
placeId: selectedHub.placeId,
|
||||
latitude: selectedHub.latitude,
|
||||
longitude: selectedHub.longitude,
|
||||
city: selectedHub.city,
|
||||
state: selectedHub.state,
|
||||
street: selectedHub.street,
|
||||
country: selectedHub.country,
|
||||
zipCode: selectedHub.zipCode,
|
||||
),
|
||||
eventName: state.eventName,
|
||||
vendorId: state.selectedVendor?.id,
|
||||
roleRates: roleRates,
|
||||
);
|
||||
await _createPermanentOrderUseCase(
|
||||
PermanentOrderArguments(order: order),
|
||||
);
|
||||
emit(state.copyWith(status: PermanentOrderStatus.success));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: PermanentOrderStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _sortDays(List<String> days) {
|
||||
days.sort(
|
||||
(String a, String b) =>
|
||||
_dayLabels.indexOf(a).compareTo(_dayLabels.indexOf(b)),
|
||||
);
|
||||
return days;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show Vendor;
|
||||
import 'permanent_order_state.dart';
|
||||
|
||||
abstract class PermanentOrderEvent extends Equatable {
|
||||
const PermanentOrderEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
class PermanentOrderVendorsLoaded extends PermanentOrderEvent {
|
||||
const PermanentOrderVendorsLoaded(this.vendors);
|
||||
|
||||
final List<Vendor> vendors;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[vendors];
|
||||
}
|
||||
|
||||
class PermanentOrderVendorChanged extends PermanentOrderEvent {
|
||||
const PermanentOrderVendorChanged(this.vendor);
|
||||
|
||||
final Vendor vendor;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[vendor];
|
||||
}
|
||||
|
||||
class PermanentOrderHubsLoaded extends PermanentOrderEvent {
|
||||
const PermanentOrderHubsLoaded(this.hubs);
|
||||
|
||||
final List<PermanentOrderHubOption> hubs;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[hubs];
|
||||
}
|
||||
|
||||
class PermanentOrderHubChanged extends PermanentOrderEvent {
|
||||
const PermanentOrderHubChanged(this.hub);
|
||||
|
||||
final PermanentOrderHubOption hub;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[hub];
|
||||
}
|
||||
|
||||
class PermanentOrderEventNameChanged extends PermanentOrderEvent {
|
||||
const PermanentOrderEventNameChanged(this.eventName);
|
||||
|
||||
final String eventName;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[eventName];
|
||||
}
|
||||
|
||||
class PermanentOrderStartDateChanged extends PermanentOrderEvent {
|
||||
const PermanentOrderStartDateChanged(this.date);
|
||||
|
||||
final DateTime date;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[date];
|
||||
}
|
||||
|
||||
class PermanentOrderDayToggled extends PermanentOrderEvent {
|
||||
const PermanentOrderDayToggled(this.dayIndex);
|
||||
|
||||
final int dayIndex;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[dayIndex];
|
||||
}
|
||||
|
||||
class PermanentOrderPositionAdded extends PermanentOrderEvent {
|
||||
const PermanentOrderPositionAdded();
|
||||
}
|
||||
|
||||
class PermanentOrderPositionRemoved extends PermanentOrderEvent {
|
||||
const PermanentOrderPositionRemoved(this.index);
|
||||
|
||||
final int index;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[index];
|
||||
}
|
||||
|
||||
class PermanentOrderPositionUpdated extends PermanentOrderEvent {
|
||||
const PermanentOrderPositionUpdated(this.index, this.position);
|
||||
|
||||
final int index;
|
||||
final PermanentOrderPosition position;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[index, position];
|
||||
}
|
||||
|
||||
class PermanentOrderSubmitted extends PermanentOrderEvent {
|
||||
const PermanentOrderSubmitted();
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
enum PermanentOrderStatus { initial, loading, success, failure }
|
||||
|
||||
class PermanentOrderState extends Equatable {
|
||||
const PermanentOrderState({
|
||||
required this.startDate,
|
||||
required this.permanentDays,
|
||||
required this.location,
|
||||
required this.eventName,
|
||||
required this.positions,
|
||||
required this.autoSelectedDayIndex,
|
||||
this.status = PermanentOrderStatus.initial,
|
||||
this.errorMessage,
|
||||
this.vendors = const <Vendor>[],
|
||||
this.selectedVendor,
|
||||
this.hubs = const <PermanentOrderHubOption>[],
|
||||
this.selectedHub,
|
||||
this.roles = const <PermanentOrderRoleOption>[],
|
||||
});
|
||||
|
||||
factory PermanentOrderState.initial() {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime start = DateTime(now.year, now.month, now.day);
|
||||
final List<String> dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
final int weekdayIndex = now.weekday % 7;
|
||||
return PermanentOrderState(
|
||||
startDate: start,
|
||||
permanentDays: <String>[dayLabels[weekdayIndex]],
|
||||
location: '',
|
||||
eventName: '',
|
||||
positions: const <PermanentOrderPosition>[
|
||||
PermanentOrderPosition(role: '', count: 1, startTime: '', endTime: ''),
|
||||
],
|
||||
autoSelectedDayIndex: weekdayIndex,
|
||||
vendors: const <Vendor>[],
|
||||
hubs: const <PermanentOrderHubOption>[],
|
||||
roles: const <PermanentOrderRoleOption>[],
|
||||
);
|
||||
}
|
||||
|
||||
final DateTime startDate;
|
||||
final List<String> permanentDays;
|
||||
final String location;
|
||||
final String eventName;
|
||||
final List<PermanentOrderPosition> positions;
|
||||
final int? autoSelectedDayIndex;
|
||||
final PermanentOrderStatus status;
|
||||
final String? errorMessage;
|
||||
final List<Vendor> vendors;
|
||||
final Vendor? selectedVendor;
|
||||
final List<PermanentOrderHubOption> hubs;
|
||||
final PermanentOrderHubOption? selectedHub;
|
||||
final List<PermanentOrderRoleOption> roles;
|
||||
|
||||
PermanentOrderState copyWith({
|
||||
DateTime? startDate,
|
||||
List<String>? permanentDays,
|
||||
String? location,
|
||||
String? eventName,
|
||||
List<PermanentOrderPosition>? positions,
|
||||
int? autoSelectedDayIndex,
|
||||
PermanentOrderStatus? status,
|
||||
String? errorMessage,
|
||||
List<Vendor>? vendors,
|
||||
Vendor? selectedVendor,
|
||||
List<PermanentOrderHubOption>? hubs,
|
||||
PermanentOrderHubOption? selectedHub,
|
||||
List<PermanentOrderRoleOption>? roles,
|
||||
}) {
|
||||
return PermanentOrderState(
|
||||
startDate: startDate ?? this.startDate,
|
||||
permanentDays: permanentDays ?? this.permanentDays,
|
||||
location: location ?? this.location,
|
||||
eventName: eventName ?? this.eventName,
|
||||
positions: positions ?? this.positions,
|
||||
autoSelectedDayIndex: autoSelectedDayIndex ?? this.autoSelectedDayIndex,
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
vendors: vendors ?? this.vendors,
|
||||
selectedVendor: selectedVendor ?? this.selectedVendor,
|
||||
hubs: hubs ?? this.hubs,
|
||||
selectedHub: selectedHub ?? this.selectedHub,
|
||||
roles: roles ?? this.roles,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isValid {
|
||||
return eventName.isNotEmpty &&
|
||||
selectedVendor != null &&
|
||||
selectedHub != null &&
|
||||
positions.isNotEmpty &&
|
||||
permanentDays.isNotEmpty &&
|
||||
positions.every(
|
||||
(PermanentOrderPosition p) =>
|
||||
p.role.isNotEmpty &&
|
||||
p.count > 0 &&
|
||||
p.startTime.isNotEmpty &&
|
||||
p.endTime.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
startDate,
|
||||
permanentDays,
|
||||
location,
|
||||
eventName,
|
||||
positions,
|
||||
autoSelectedDayIndex,
|
||||
status,
|
||||
errorMessage,
|
||||
vendors,
|
||||
selectedVendor,
|
||||
hubs,
|
||||
selectedHub,
|
||||
roles,
|
||||
];
|
||||
}
|
||||
|
||||
class PermanentOrderHubOption extends Equatable {
|
||||
const PermanentOrderHubOption({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.placeId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.city,
|
||||
this.state,
|
||||
this.street,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String address;
|
||||
final String? placeId;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? city;
|
||||
final String? state;
|
||||
final String? street;
|
||||
final String? country;
|
||||
final String? zipCode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
|
||||
class PermanentOrderRoleOption extends Equatable {
|
||||
const PermanentOrderRoleOption({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.costPerHour,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final double costPerHour;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, name, costPerHour];
|
||||
}
|
||||
|
||||
class PermanentOrderPosition extends Equatable {
|
||||
const PermanentOrderPosition({
|
||||
required this.role,
|
||||
required this.count,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.lunchBreak,
|
||||
});
|
||||
|
||||
final String role;
|
||||
final int count;
|
||||
final String startTime;
|
||||
final String endTime;
|
||||
final String? lunchBreak;
|
||||
|
||||
PermanentOrderPosition copyWith({
|
||||
String? role,
|
||||
int? count,
|
||||
String? startTime,
|
||||
String? endTime,
|
||||
String? lunchBreak,
|
||||
}) {
|
||||
return PermanentOrderPosition(
|
||||
role: role ?? this.role,
|
||||
count: count ?? this.count,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
lunchBreak: lunchBreak ?? this.lunchBreak,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[role, count, startTime, endTime, lunchBreak];
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
import '../../domain/arguments/recurring_order_arguments.dart';
|
||||
import '../../domain/usecases/create_recurring_order_usecase.dart';
|
||||
import 'recurring_order_event.dart';
|
||||
import 'recurring_order_state.dart';
|
||||
|
||||
/// BLoC for managing the recurring order creation form.
|
||||
class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
|
||||
with BlocErrorHandler<RecurringOrderState>, SafeBloc<RecurringOrderEvent, RecurringOrderState> {
|
||||
RecurringOrderBloc(this._createRecurringOrderUseCase, this._service)
|
||||
: super(RecurringOrderState.initial()) {
|
||||
on<RecurringOrderVendorsLoaded>(_onVendorsLoaded);
|
||||
on<RecurringOrderVendorChanged>(_onVendorChanged);
|
||||
on<RecurringOrderHubsLoaded>(_onHubsLoaded);
|
||||
on<RecurringOrderHubChanged>(_onHubChanged);
|
||||
on<RecurringOrderEventNameChanged>(_onEventNameChanged);
|
||||
on<RecurringOrderStartDateChanged>(_onStartDateChanged);
|
||||
on<RecurringOrderEndDateChanged>(_onEndDateChanged);
|
||||
on<RecurringOrderDayToggled>(_onDayToggled);
|
||||
on<RecurringOrderPositionAdded>(_onPositionAdded);
|
||||
on<RecurringOrderPositionRemoved>(_onPositionRemoved);
|
||||
on<RecurringOrderPositionUpdated>(_onPositionUpdated);
|
||||
on<RecurringOrderSubmitted>(_onSubmitted);
|
||||
|
||||
_loadVendors();
|
||||
_loadHubs();
|
||||
}
|
||||
|
||||
final CreateRecurringOrderUseCase _createRecurringOrderUseCase;
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
static const List<String> _dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
|
||||
Future<void> _loadVendors() async {
|
||||
final List<domain.Vendor>? vendors = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final QueryResult<dc.ListVendorsData, void> result =
|
||||
await _service.connector.listVendors().execute();
|
||||
return result.data.vendors
|
||||
.map(
|
||||
(dc.ListVendorsVendors vendor) => domain.Vendor(
|
||||
id: vendor.id,
|
||||
name: vendor.companyName,
|
||||
rates: const <String, double>{},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
onError: (_) => add(const RecurringOrderVendorsLoaded(<domain.Vendor>[])),
|
||||
);
|
||||
|
||||
if (vendors != null) {
|
||||
add(RecurringOrderVendorsLoaded(vendors));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRolesForVendor(
|
||||
String vendorId,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) async {
|
||||
final List<RecurringOrderRoleOption>? roles = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final QueryResult<dc.ListRolesByVendorIdData, dc.ListRolesByVendorIdVariables>
|
||||
result = await _service.connector
|
||||
.listRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
return result.data.roles
|
||||
.map(
|
||||
(dc.ListRolesByVendorIdRoles role) => RecurringOrderRoleOption(
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
costPerHour: role.costPerHour,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
onError: (_) => emit(state.copyWith(roles: const <RecurringOrderRoleOption>[])),
|
||||
);
|
||||
|
||||
if (roles != null) {
|
||||
emit(state.copyWith(roles: roles));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadHubs() async {
|
||||
final List<RecurringOrderHubOption>? hubs = await handleErrorWithResult(
|
||||
action: () async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final QueryResult<dc.ListTeamHubsByOwnerIdData, dc.ListTeamHubsByOwnerIdVariables>
|
||||
result = await _service.connector
|
||||
.listTeamHubsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
return result.data.teamHubs
|
||||
.map(
|
||||
(dc.ListTeamHubsByOwnerIdTeamHubs hub) => RecurringOrderHubOption(
|
||||
id: hub.id,
|
||||
name: hub.hubName,
|
||||
address: hub.address,
|
||||
placeId: hub.placeId,
|
||||
latitude: hub.latitude,
|
||||
longitude: hub.longitude,
|
||||
city: hub.city,
|
||||
state: hub.state,
|
||||
street: hub.street,
|
||||
country: hub.country,
|
||||
zipCode: hub.zipCode,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
onError: (_) => add(const RecurringOrderHubsLoaded(<RecurringOrderHubOption>[])),
|
||||
);
|
||||
|
||||
if (hubs != null) {
|
||||
add(RecurringOrderHubsLoaded(hubs));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onVendorsLoaded(
|
||||
RecurringOrderVendorsLoaded event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) async {
|
||||
final domain.Vendor? selectedVendor =
|
||||
event.vendors.isNotEmpty ? event.vendors.first : null;
|
||||
emit(
|
||||
state.copyWith(
|
||||
vendors: event.vendors,
|
||||
selectedVendor: selectedVendor,
|
||||
),
|
||||
);
|
||||
if (selectedVendor != null) {
|
||||
await _loadRolesForVendor(selectedVendor.id, emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onVendorChanged(
|
||||
RecurringOrderVendorChanged event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(selectedVendor: event.vendor));
|
||||
await _loadRolesForVendor(event.vendor.id, emit);
|
||||
}
|
||||
|
||||
void _onHubsLoaded(
|
||||
RecurringOrderHubsLoaded event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) {
|
||||
final RecurringOrderHubOption? selectedHub =
|
||||
event.hubs.isNotEmpty ? event.hubs.first : null;
|
||||
emit(
|
||||
state.copyWith(
|
||||
hubs: event.hubs,
|
||||
selectedHub: selectedHub,
|
||||
location: selectedHub?.name ?? '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onHubChanged(
|
||||
RecurringOrderHubChanged event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedHub: event.hub,
|
||||
location: event.hub.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onEventNameChanged(
|
||||
RecurringOrderEventNameChanged event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) {
|
||||
emit(state.copyWith(eventName: event.eventName));
|
||||
}
|
||||
|
||||
void _onStartDateChanged(
|
||||
RecurringOrderStartDateChanged event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) {
|
||||
DateTime endDate = state.endDate;
|
||||
if (endDate.isBefore(event.date)) {
|
||||
endDate = event.date;
|
||||
}
|
||||
final int newDayIndex = event.date.weekday % 7;
|
||||
final int? autoIndex = state.autoSelectedDayIndex;
|
||||
List<String> days = List<String>.from(state.recurringDays);
|
||||
if (autoIndex != null) {
|
||||
final String oldDay = _dayLabels[autoIndex];
|
||||
days.remove(oldDay);
|
||||
final String newDay = _dayLabels[newDayIndex];
|
||||
if (!days.contains(newDay)) {
|
||||
days.add(newDay);
|
||||
}
|
||||
days = _sortDays(days);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
startDate: event.date,
|
||||
endDate: endDate,
|
||||
recurringDays: days,
|
||||
autoSelectedDayIndex: autoIndex == null ? null : newDayIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onEndDateChanged(
|
||||
RecurringOrderEndDateChanged event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) {
|
||||
DateTime startDate = state.startDate;
|
||||
if (event.date.isBefore(startDate)) {
|
||||
startDate = event.date;
|
||||
}
|
||||
emit(state.copyWith(endDate: event.date, startDate: startDate));
|
||||
}
|
||||
|
||||
void _onDayToggled(
|
||||
RecurringOrderDayToggled event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) {
|
||||
final List<String> days = List<String>.from(state.recurringDays);
|
||||
final String label = _dayLabels[event.dayIndex];
|
||||
int? autoIndex = state.autoSelectedDayIndex;
|
||||
if (days.contains(label)) {
|
||||
days.remove(label);
|
||||
if (autoIndex == event.dayIndex) {
|
||||
autoIndex = null;
|
||||
}
|
||||
} else {
|
||||
days.add(label);
|
||||
}
|
||||
emit(state.copyWith(recurringDays: _sortDays(days), autoSelectedDayIndex: autoIndex));
|
||||
}
|
||||
|
||||
void _onPositionAdded(
|
||||
RecurringOrderPositionAdded event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) {
|
||||
final List<RecurringOrderPosition> newPositions =
|
||||
List<RecurringOrderPosition>.from(state.positions)..add(
|
||||
const RecurringOrderPosition(
|
||||
role: '',
|
||||
count: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '17:00',
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(positions: newPositions));
|
||||
}
|
||||
|
||||
void _onPositionRemoved(
|
||||
RecurringOrderPositionRemoved event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) {
|
||||
if (state.positions.length > 1) {
|
||||
final List<RecurringOrderPosition> newPositions =
|
||||
List<RecurringOrderPosition>.from(state.positions)
|
||||
..removeAt(event.index);
|
||||
emit(state.copyWith(positions: newPositions));
|
||||
}
|
||||
}
|
||||
|
||||
void _onPositionUpdated(
|
||||
RecurringOrderPositionUpdated event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) {
|
||||
final List<RecurringOrderPosition> newPositions =
|
||||
List<RecurringOrderPosition>.from(state.positions);
|
||||
newPositions[event.index] = event.position;
|
||||
emit(state.copyWith(positions: newPositions));
|
||||
}
|
||||
|
||||
Future<void> _onSubmitted(
|
||||
RecurringOrderSubmitted event,
|
||||
Emitter<RecurringOrderState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: RecurringOrderStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final Map<String, double> roleRates = <String, double>{
|
||||
for (final RecurringOrderRoleOption role in state.roles)
|
||||
role.id: role.costPerHour,
|
||||
};
|
||||
final RecurringOrderHubOption? selectedHub = state.selectedHub;
|
||||
if (selectedHub == null) {
|
||||
throw domain.OrderMissingHubException();
|
||||
}
|
||||
final domain.RecurringOrder order = domain.RecurringOrder(
|
||||
startDate: state.startDate,
|
||||
endDate: state.endDate,
|
||||
recurringDays: state.recurringDays,
|
||||
location: selectedHub.name,
|
||||
positions: state.positions
|
||||
.map(
|
||||
(RecurringOrderPosition p) => domain.RecurringOrderPosition(
|
||||
role: p.role,
|
||||
count: p.count,
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
lunchBreak: p.lunchBreak ?? 'NO_BREAK',
|
||||
location: null,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
hub: domain.RecurringOrderHubDetails(
|
||||
id: selectedHub.id,
|
||||
name: selectedHub.name,
|
||||
address: selectedHub.address,
|
||||
placeId: selectedHub.placeId,
|
||||
latitude: selectedHub.latitude,
|
||||
longitude: selectedHub.longitude,
|
||||
city: selectedHub.city,
|
||||
state: selectedHub.state,
|
||||
street: selectedHub.street,
|
||||
country: selectedHub.country,
|
||||
zipCode: selectedHub.zipCode,
|
||||
),
|
||||
eventName: state.eventName,
|
||||
vendorId: state.selectedVendor?.id,
|
||||
roleRates: roleRates,
|
||||
);
|
||||
await _createRecurringOrderUseCase(
|
||||
RecurringOrderArguments(order: order),
|
||||
);
|
||||
emit(state.copyWith(status: RecurringOrderStatus.success));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: RecurringOrderStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _sortDays(List<String> days) {
|
||||
days.sort(
|
||||
(String a, String b) =>
|
||||
_dayLabels.indexOf(a).compareTo(_dayLabels.indexOf(b)),
|
||||
);
|
||||
return days;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show Vendor;
|
||||
import 'recurring_order_state.dart';
|
||||
|
||||
abstract class RecurringOrderEvent extends Equatable {
|
||||
const RecurringOrderEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
class RecurringOrderVendorsLoaded extends RecurringOrderEvent {
|
||||
const RecurringOrderVendorsLoaded(this.vendors);
|
||||
|
||||
final List<Vendor> vendors;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[vendors];
|
||||
}
|
||||
|
||||
class RecurringOrderVendorChanged extends RecurringOrderEvent {
|
||||
const RecurringOrderVendorChanged(this.vendor);
|
||||
|
||||
final Vendor vendor;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[vendor];
|
||||
}
|
||||
|
||||
class RecurringOrderHubsLoaded extends RecurringOrderEvent {
|
||||
const RecurringOrderHubsLoaded(this.hubs);
|
||||
|
||||
final List<RecurringOrderHubOption> hubs;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[hubs];
|
||||
}
|
||||
|
||||
class RecurringOrderHubChanged extends RecurringOrderEvent {
|
||||
const RecurringOrderHubChanged(this.hub);
|
||||
|
||||
final RecurringOrderHubOption hub;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[hub];
|
||||
}
|
||||
|
||||
class RecurringOrderEventNameChanged extends RecurringOrderEvent {
|
||||
const RecurringOrderEventNameChanged(this.eventName);
|
||||
|
||||
final String eventName;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[eventName];
|
||||
}
|
||||
|
||||
class RecurringOrderStartDateChanged extends RecurringOrderEvent {
|
||||
const RecurringOrderStartDateChanged(this.date);
|
||||
|
||||
final DateTime date;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[date];
|
||||
}
|
||||
|
||||
class RecurringOrderEndDateChanged extends RecurringOrderEvent {
|
||||
const RecurringOrderEndDateChanged(this.date);
|
||||
|
||||
final DateTime date;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[date];
|
||||
}
|
||||
|
||||
class RecurringOrderDayToggled extends RecurringOrderEvent {
|
||||
const RecurringOrderDayToggled(this.dayIndex);
|
||||
|
||||
final int dayIndex;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[dayIndex];
|
||||
}
|
||||
|
||||
class RecurringOrderPositionAdded extends RecurringOrderEvent {
|
||||
const RecurringOrderPositionAdded();
|
||||
}
|
||||
|
||||
class RecurringOrderPositionRemoved extends RecurringOrderEvent {
|
||||
const RecurringOrderPositionRemoved(this.index);
|
||||
|
||||
final int index;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[index];
|
||||
}
|
||||
|
||||
class RecurringOrderPositionUpdated extends RecurringOrderEvent {
|
||||
const RecurringOrderPositionUpdated(this.index, this.position);
|
||||
|
||||
final int index;
|
||||
final RecurringOrderPosition position;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[index, position];
|
||||
}
|
||||
|
||||
class RecurringOrderSubmitted extends RecurringOrderEvent {
|
||||
const RecurringOrderSubmitted();
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
enum RecurringOrderStatus { initial, loading, success, failure }
|
||||
|
||||
class RecurringOrderState extends Equatable {
|
||||
const RecurringOrderState({
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.recurringDays,
|
||||
required this.location,
|
||||
required this.eventName,
|
||||
required this.positions,
|
||||
required this.autoSelectedDayIndex,
|
||||
this.status = RecurringOrderStatus.initial,
|
||||
this.errorMessage,
|
||||
this.vendors = const <Vendor>[],
|
||||
this.selectedVendor,
|
||||
this.hubs = const <RecurringOrderHubOption>[],
|
||||
this.selectedHub,
|
||||
this.roles = const <RecurringOrderRoleOption>[],
|
||||
});
|
||||
|
||||
factory RecurringOrderState.initial() {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime start = DateTime(now.year, now.month, now.day);
|
||||
final List<String> dayLabels = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
final int weekdayIndex = now.weekday % 7;
|
||||
return RecurringOrderState(
|
||||
startDate: start,
|
||||
endDate: start.add(const Duration(days: 7)),
|
||||
recurringDays: <String>[dayLabels[weekdayIndex]],
|
||||
location: '',
|
||||
eventName: '',
|
||||
positions: const <RecurringOrderPosition>[
|
||||
RecurringOrderPosition(role: '', count: 1, startTime: '', endTime: ''),
|
||||
],
|
||||
autoSelectedDayIndex: weekdayIndex,
|
||||
vendors: const <Vendor>[],
|
||||
hubs: const <RecurringOrderHubOption>[],
|
||||
roles: const <RecurringOrderRoleOption>[],
|
||||
);
|
||||
}
|
||||
|
||||
final DateTime startDate;
|
||||
final DateTime endDate;
|
||||
final List<String> recurringDays;
|
||||
final String location;
|
||||
final String eventName;
|
||||
final List<RecurringOrderPosition> positions;
|
||||
final int? autoSelectedDayIndex;
|
||||
final RecurringOrderStatus status;
|
||||
final String? errorMessage;
|
||||
final List<Vendor> vendors;
|
||||
final Vendor? selectedVendor;
|
||||
final List<RecurringOrderHubOption> hubs;
|
||||
final RecurringOrderHubOption? selectedHub;
|
||||
final List<RecurringOrderRoleOption> roles;
|
||||
|
||||
RecurringOrderState copyWith({
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
List<String>? recurringDays,
|
||||
String? location,
|
||||
String? eventName,
|
||||
List<RecurringOrderPosition>? positions,
|
||||
int? autoSelectedDayIndex,
|
||||
RecurringOrderStatus? status,
|
||||
String? errorMessage,
|
||||
List<Vendor>? vendors,
|
||||
Vendor? selectedVendor,
|
||||
List<RecurringOrderHubOption>? hubs,
|
||||
RecurringOrderHubOption? selectedHub,
|
||||
List<RecurringOrderRoleOption>? roles,
|
||||
}) {
|
||||
return RecurringOrderState(
|
||||
startDate: startDate ?? this.startDate,
|
||||
endDate: endDate ?? this.endDate,
|
||||
recurringDays: recurringDays ?? this.recurringDays,
|
||||
location: location ?? this.location,
|
||||
eventName: eventName ?? this.eventName,
|
||||
positions: positions ?? this.positions,
|
||||
autoSelectedDayIndex: autoSelectedDayIndex ?? this.autoSelectedDayIndex,
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
vendors: vendors ?? this.vendors,
|
||||
selectedVendor: selectedVendor ?? this.selectedVendor,
|
||||
hubs: hubs ?? this.hubs,
|
||||
selectedHub: selectedHub ?? this.selectedHub,
|
||||
roles: roles ?? this.roles,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isValid {
|
||||
final bool datesValid = !endDate.isBefore(startDate);
|
||||
return eventName.isNotEmpty &&
|
||||
selectedVendor != null &&
|
||||
selectedHub != null &&
|
||||
positions.isNotEmpty &&
|
||||
recurringDays.isNotEmpty &&
|
||||
datesValid &&
|
||||
positions.every(
|
||||
(RecurringOrderPosition p) =>
|
||||
p.role.isNotEmpty &&
|
||||
p.count > 0 &&
|
||||
p.startTime.isNotEmpty &&
|
||||
p.endTime.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
startDate,
|
||||
endDate,
|
||||
recurringDays,
|
||||
location,
|
||||
eventName,
|
||||
positions,
|
||||
autoSelectedDayIndex,
|
||||
status,
|
||||
errorMessage,
|
||||
vendors,
|
||||
selectedVendor,
|
||||
hubs,
|
||||
selectedHub,
|
||||
roles,
|
||||
];
|
||||
}
|
||||
|
||||
class RecurringOrderHubOption extends Equatable {
|
||||
const RecurringOrderHubOption({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.placeId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.city,
|
||||
this.state,
|
||||
this.street,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String address;
|
||||
final String? placeId;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? city;
|
||||
final String? state;
|
||||
final String? street;
|
||||
final String? country;
|
||||
final String? zipCode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
|
||||
class RecurringOrderRoleOption extends Equatable {
|
||||
const RecurringOrderRoleOption({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.costPerHour,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final double costPerHour;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, name, costPerHour];
|
||||
}
|
||||
|
||||
class RecurringOrderPosition extends Equatable {
|
||||
const RecurringOrderPosition({
|
||||
required this.role,
|
||||
required this.count,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.lunchBreak,
|
||||
});
|
||||
|
||||
final String role;
|
||||
final int count;
|
||||
final String startTime;
|
||||
final String endTime;
|
||||
final String? lunchBreak;
|
||||
|
||||
RecurringOrderPosition copyWith({
|
||||
String? role,
|
||||
int? count,
|
||||
String? startTime,
|
||||
String? endTime,
|
||||
String? lunchBreak,
|
||||
}) {
|
||||
return RecurringOrderPosition(
|
||||
role: role ?? this.role,
|
||||
count: count ?? this.count,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
lunchBreak: lunchBreak ?? this.lunchBreak,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[role, count, startTime, endTime, lunchBreak];
|
||||
}
|
||||
@@ -1,40 +1,19 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../blocs/permanent_order_bloc.dart';
|
||||
import '../widgets/permanent_order/permanent_order_view.dart';
|
||||
|
||||
/// Permanent Order Page - Long-term staffing placement.
|
||||
/// Placeholder for future implementation.
|
||||
/// Page for creating a permanent staffing order.
|
||||
class PermanentOrderPage extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderPage].
|
||||
const PermanentOrderPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderPermanentEn labels =
|
||||
t.client_create_order.permanent;
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: labels.title,
|
||||
onLeadingPressed: () => Modular.to.navigate(ClientPaths.createOrder),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
labels.subtitle,
|
||||
style: UiTypography.body1r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
return BlocProvider<PermanentOrderBloc>(
|
||||
create: (BuildContext context) => Modular.get<PermanentOrderBloc>(),
|
||||
child: const PermanentOrderView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../blocs/recurring_order_bloc.dart';
|
||||
import '../widgets/recurring_order/recurring_order_view.dart';
|
||||
|
||||
/// Recurring Order Page - Ongoing weekly/monthly coverage.
|
||||
/// Placeholder for future implementation.
|
||||
/// Page for creating a recurring staffing order.
|
||||
class RecurringOrderPage extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderPage].
|
||||
const RecurringOrderPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderRecurringEn labels =
|
||||
t.client_create_order.recurring;
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: labels.title,
|
||||
onLeadingPressed: () => Modular.to.toClientHome(),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
labels.subtitle,
|
||||
style: UiTypography.body1r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
return BlocProvider<RecurringOrderBloc>(
|
||||
create: (BuildContext context) => Modular.get<RecurringOrderBloc>(),
|
||||
child: const RecurringOrderView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// A date picker field for the permanent order form.
|
||||
class PermanentOrderDatePicker extends StatefulWidget {
|
||||
/// Creates a [PermanentOrderDatePicker].
|
||||
const PermanentOrderDatePicker({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The label text to display above the field.
|
||||
final String label;
|
||||
|
||||
/// The currently selected date.
|
||||
final DateTime value;
|
||||
|
||||
/// Callback when a new date is selected.
|
||||
final ValueChanged<DateTime> onChanged;
|
||||
|
||||
@override
|
||||
State<PermanentOrderDatePicker> createState() =>
|
||||
_PermanentOrderDatePickerState();
|
||||
}
|
||||
|
||||
class _PermanentOrderDatePickerState extends State<PermanentOrderDatePicker> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(
|
||||
text: DateFormat('yyyy-MM-dd').format(widget.value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PermanentOrderDatePicker oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value != oldWidget.value) {
|
||||
_controller.text = DateFormat('yyyy-MM-dd').format(widget.value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiTextField(
|
||||
label: widget.label,
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
prefixIcon: UiIcons.calendar,
|
||||
onTap: () async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: widget.value,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (picked != null) {
|
||||
widget.onChanged(picked);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A text input for the order name in the permanent order form.
|
||||
class PermanentOrderEventNameInput extends StatefulWidget {
|
||||
const PermanentOrderEventNameInput({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
State<PermanentOrderEventNameInput> createState() =>
|
||||
_PermanentOrderEventNameInputState();
|
||||
}
|
||||
|
||||
class _PermanentOrderEventNameInputState
|
||||
extends State<PermanentOrderEventNameInput> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PermanentOrderEventNameInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value != _controller.text) {
|
||||
_controller.text = widget.value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiTextField(
|
||||
label: widget.label,
|
||||
controller: _controller,
|
||||
onChanged: widget.onChanged,
|
||||
hintText: 'Order name',
|
||||
prefixIcon: UiIcons.briefcase,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A header widget for the permanent order flow with a colored background.
|
||||
class PermanentOrderHeader extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderHeader].
|
||||
const PermanentOrderHeader({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onBack,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The title of the page.
|
||||
final String title;
|
||||
|
||||
/// The subtitle or description.
|
||||
final String subtitle;
|
||||
|
||||
/// Callback when the back button is pressed.
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + UiConstants.space5,
|
||||
bottom: UiConstants.space5,
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
),
|
||||
color: UiColors.primary,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: onBack,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.chevronLeft,
|
||||
color: UiColors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.headline3m.copyWith(color: UiColors.white),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: UiColors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../blocs/permanent_order_state.dart';
|
||||
|
||||
/// A card widget for editing a specific position in a permanent order.
|
||||
class PermanentOrderPositionCard extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderPositionCard].
|
||||
const PermanentOrderPositionCard({
|
||||
required this.index,
|
||||
required this.position,
|
||||
required this.isRemovable,
|
||||
required this.onUpdated,
|
||||
required this.onRemoved,
|
||||
required this.positionLabel,
|
||||
required this.roleLabel,
|
||||
required this.workersLabel,
|
||||
required this.startLabel,
|
||||
required this.endLabel,
|
||||
required this.lunchLabel,
|
||||
required this.roles,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The index of the position in the list.
|
||||
final int index;
|
||||
|
||||
/// The position entity data.
|
||||
final PermanentOrderPosition position;
|
||||
|
||||
/// Whether this position can be removed (usually if there's more than one).
|
||||
final bool isRemovable;
|
||||
|
||||
/// Callback when the position data is updated.
|
||||
final ValueChanged<PermanentOrderPosition> onUpdated;
|
||||
|
||||
/// Callback when the position is removed.
|
||||
final VoidCallback onRemoved;
|
||||
|
||||
/// Label for positions (e.g., "Position").
|
||||
final String positionLabel;
|
||||
|
||||
/// Label for the role selection.
|
||||
final String roleLabel;
|
||||
|
||||
/// Label for the worker count.
|
||||
final String workersLabel;
|
||||
|
||||
/// Label for the start time.
|
||||
final String startLabel;
|
||||
|
||||
/// Label for the end time.
|
||||
final String endLabel;
|
||||
|
||||
/// Label for the lunch break.
|
||||
final String lunchLabel;
|
||||
|
||||
/// Available roles for the selected vendor.
|
||||
final List<PermanentOrderRoleOption> roles;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'$positionLabel #${index + 1}',
|
||||
style: UiTypography.footnote1m.textSecondary,
|
||||
),
|
||||
if (isRemovable)
|
||||
GestureDetector(
|
||||
onTap: onRemoved,
|
||||
child: Text(
|
||||
t.client_create_order.one_time.remove,
|
||||
style: UiTypography.footnote1m.copyWith(
|
||||
color: UiColors.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Role (Dropdown)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
hint: Text(
|
||||
roleLabel,
|
||||
style: UiTypography.body2r.textPlaceholder,
|
||||
),
|
||||
value: position.role.isEmpty ? null : position.role,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (String? val) {
|
||||
if (val != null) {
|
||||
onUpdated(position.copyWith(role: val));
|
||||
}
|
||||
},
|
||||
items: _buildRoleItems(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Start/End/Workers Row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
// Start Time
|
||||
Expanded(
|
||||
child: _buildTimeInput(
|
||||
context: context,
|
||||
label: startLabel,
|
||||
value: position.startTime,
|
||||
onTap: () async {
|
||||
final TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.now(),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
onUpdated(
|
||||
position.copyWith(startTime: picked.format(context)),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
// End Time
|
||||
Expanded(
|
||||
child: _buildTimeInput(
|
||||
context: context,
|
||||
label: endLabel,
|
||||
value: position.endTime,
|
||||
onTap: () async {
|
||||
final TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.now(),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
onUpdated(
|
||||
position.copyWith(endTime: picked.format(context)),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
// Workers Count
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
workersLabel,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (position.count > 1) {
|
||||
onUpdated(
|
||||
position.copyWith(count: position.count - 1),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Icon(UiIcons.minus, size: 12),
|
||||
),
|
||||
Text(
|
||||
'${position.count}',
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
onUpdated(
|
||||
position.copyWith(count: position.count + 1),
|
||||
);
|
||||
},
|
||||
child: const Icon(UiIcons.add, size: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Lunch Break
|
||||
Text(lunchLabel, style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
value: position.lunchBreak,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (String? val) {
|
||||
if (val != null) {
|
||||
onUpdated(position.copyWith(lunchBreak: val));
|
||||
}
|
||||
},
|
||||
items: <String>[
|
||||
'NO_BREAK',
|
||||
'MIN_10',
|
||||
'MIN_15',
|
||||
'MIN_30',
|
||||
'MIN_45',
|
||||
'MIN_60',
|
||||
].map((String value) {
|
||||
final String label = switch (value) {
|
||||
'NO_BREAK' => 'No Break',
|
||||
'MIN_10' => '10 min (Paid)',
|
||||
'MIN_15' => '15 min (Paid)',
|
||||
'MIN_30' => '30 min (Unpaid)',
|
||||
'MIN_45' => '45 min (Unpaid)',
|
||||
'MIN_60' => '60 min (Unpaid)',
|
||||
_ => value,
|
||||
};
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimeInput({
|
||||
required BuildContext context,
|
||||
required String label,
|
||||
required String value,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(label, style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
value.isEmpty ? '--:--' : value,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: 14,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<DropdownMenuItem<String>> _buildRoleItems() {
|
||||
final List<DropdownMenuItem<String>> items = roles
|
||||
.map(
|
||||
(PermanentOrderRoleOption role) => DropdownMenuItem<String>(
|
||||
value: role.id,
|
||||
child: Text(
|
||||
'${role.name} - \$${role.costPerHour.toStringAsFixed(0)}',
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final bool hasSelected = roles.any((PermanentOrderRoleOption role) => role.id == position.role);
|
||||
if (position.role.isNotEmpty && !hasSelected) {
|
||||
items.add(
|
||||
DropdownMenuItem<String>(
|
||||
value: position.role,
|
||||
child: Text(
|
||||
position.role,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A header widget for sections in the permanent order form.
|
||||
class PermanentOrderSectionHeader extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderSectionHeader].
|
||||
const PermanentOrderSectionHeader({
|
||||
required this.title,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The title text for the section.
|
||||
final String title;
|
||||
|
||||
/// Optional label for an action button on the right.
|
||||
final String? actionLabel;
|
||||
|
||||
/// Callback when the action button is tapped.
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(title, style: UiTypography.headline4m.textPrimary),
|
||||
if (actionLabel != null && onAction != null)
|
||||
TextButton(
|
||||
onPressed: onAction,
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.add, size: 16, color: UiColors.primary),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
actionLabel!,
|
||||
style: UiTypography.body2m.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A view to display when a permanent order has been successfully created.
|
||||
class PermanentOrderSuccessView extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderSuccessView].
|
||||
const PermanentOrderSuccessView({
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.buttonLabel,
|
||||
required this.onDone,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The title of the success message.
|
||||
final String title;
|
||||
|
||||
/// The body of the success message.
|
||||
final String message;
|
||||
|
||||
/// Label for the completion button.
|
||||
final String buttonLabel;
|
||||
|
||||
/// Callback when the completion button is tapped.
|
||||
final VoidCallback onDone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: <Color>[UiColors.primary, UiColors.buttonPrimaryHover],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10),
|
||||
padding: const EdgeInsets.all(UiConstants.space8),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg * 1.5,
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, UiConstants.space2 + 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space16,
|
||||
height: UiConstants.space16,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.accent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.check,
|
||||
color: UiColors.black,
|
||||
size: UiConstants.space8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.headline2m.textPrimary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.textSecondary.copyWith(
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: buttonLabel,
|
||||
onPressed: onDone,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show Vendor;
|
||||
import '../../blocs/permanent_order_bloc.dart';
|
||||
import '../../blocs/permanent_order_event.dart';
|
||||
import '../../blocs/permanent_order_state.dart';
|
||||
import 'permanent_order_date_picker.dart';
|
||||
import 'permanent_order_event_name_input.dart';
|
||||
import 'permanent_order_header.dart';
|
||||
import 'permanent_order_position_card.dart';
|
||||
import 'permanent_order_section_header.dart';
|
||||
import 'permanent_order_success_view.dart';
|
||||
|
||||
/// The main content of the Permanent Order page.
|
||||
class PermanentOrderView extends StatelessWidget {
|
||||
/// Creates a [PermanentOrderView].
|
||||
const PermanentOrderView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderPermanentEn labels =
|
||||
t.client_create_order.permanent;
|
||||
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return BlocConsumer<PermanentOrderBloc, PermanentOrderState>(
|
||||
listener: (BuildContext context, PermanentOrderState state) {
|
||||
if (state.status == PermanentOrderStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
final String message = translateErrorKey(state.errorMessage!);
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: message,
|
||||
type: UiSnackbarType.error,
|
||||
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, PermanentOrderState state) {
|
||||
if (state.status == PermanentOrderStatus.success) {
|
||||
return PermanentOrderSuccessView(
|
||||
title: labels.title,
|
||||
message: labels.subtitle,
|
||||
buttonLabel: oneTimeLabels.back_to_orders,
|
||||
onDone: () => Modular.to.pushNamedAndRemoveUntil(
|
||||
ClientPaths.orders,
|
||||
(_) => false,
|
||||
arguments: <String, dynamic>{
|
||||
'initialDate': state.startDate.toIso8601String(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.vendors.isEmpty &&
|
||||
state.status != PermanentOrderStatus.loading) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
PermanentOrderHeader(
|
||||
title: labels.title,
|
||||
subtitle: labels.subtitle,
|
||||
onBack: () => Modular.to.navigate(ClientPaths.createOrder),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 64,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
'No Vendors Available',
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
'There are no staffing vendors associated with your account.',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
PermanentOrderHeader(
|
||||
title: labels.title,
|
||||
subtitle: labels.subtitle,
|
||||
onBack: () => Modular.to.navigate(ClientPaths.createOrder),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
_PermanentOrderForm(state: state),
|
||||
if (state.status == PermanentOrderStatus.loading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
_BottomActionButton(
|
||||
label: state.status == PermanentOrderStatus.loading
|
||||
? oneTimeLabels.creating
|
||||
: oneTimeLabels.create_order,
|
||||
isLoading: state.status == PermanentOrderStatus.loading,
|
||||
onPressed: state.isValid
|
||||
? () => BlocProvider.of<PermanentOrderBloc>(
|
||||
context,
|
||||
).add(const PermanentOrderSubmitted())
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PermanentOrderForm extends StatelessWidget {
|
||||
const _PermanentOrderForm({required this.state});
|
||||
final PermanentOrderState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderPermanentEn labels =
|
||||
t.client_create_order.permanent;
|
||||
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
children: <Widget>[
|
||||
Text(
|
||||
labels.title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
PermanentOrderEventNameInput(
|
||||
label: 'ORDER NAME',
|
||||
value: state.eventName,
|
||||
onChanged: (String value) => BlocProvider.of<PermanentOrderBloc>(
|
||||
context,
|
||||
).add(PermanentOrderEventNameChanged(value)),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Vendor Selection
|
||||
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Vendor>(
|
||||
isExpanded: true,
|
||||
value: state.selectedVendor,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (Vendor? vendor) {
|
||||
if (vendor != null) {
|
||||
BlocProvider.of<PermanentOrderBloc>(
|
||||
context,
|
||||
).add(PermanentOrderVendorChanged(vendor));
|
||||
}
|
||||
},
|
||||
items: state.vendors.map((Vendor vendor) {
|
||||
return DropdownMenuItem<Vendor>(
|
||||
value: vendor,
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
PermanentOrderDatePicker(
|
||||
label: 'Start Date',
|
||||
value: state.startDate,
|
||||
onChanged: (DateTime date) => BlocProvider.of<PermanentOrderBloc>(
|
||||
context,
|
||||
).add(PermanentOrderStartDateChanged(date)),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('Permanent Days', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_PermanentDaysSelector(
|
||||
selectedDays: state.permanentDays,
|
||||
onToggle: (int dayIndex) => BlocProvider.of<PermanentOrderBloc>(
|
||||
context,
|
||||
).add(PermanentOrderDayToggled(dayIndex)),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('HUB', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<PermanentOrderHubOption>(
|
||||
isExpanded: true,
|
||||
value: state.selectedHub,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (PermanentOrderHubOption? hub) {
|
||||
if (hub != null) {
|
||||
BlocProvider.of<PermanentOrderBloc>(
|
||||
context,
|
||||
).add(PermanentOrderHubChanged(hub));
|
||||
}
|
||||
},
|
||||
items: state.hubs.map((PermanentOrderHubOption hub) {
|
||||
return DropdownMenuItem<PermanentOrderHubOption>(
|
||||
value: hub,
|
||||
child: Text(
|
||||
hub.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
PermanentOrderSectionHeader(
|
||||
title: oneTimeLabels.positions_title,
|
||||
actionLabel: oneTimeLabels.add_position,
|
||||
onAction: () => BlocProvider.of<PermanentOrderBloc>(
|
||||
context,
|
||||
).add(const PermanentOrderPositionAdded()),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Positions List
|
||||
...state.positions.asMap().entries.map((
|
||||
MapEntry<int, PermanentOrderPosition> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final PermanentOrderPosition position = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: PermanentOrderPositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
isRemovable: state.positions.length > 1,
|
||||
positionLabel: oneTimeLabels.positions_title,
|
||||
roleLabel: oneTimeLabels.select_role,
|
||||
workersLabel: oneTimeLabels.workers_label,
|
||||
startLabel: oneTimeLabels.start_label,
|
||||
endLabel: oneTimeLabels.end_label,
|
||||
lunchLabel: oneTimeLabels.lunch_break_label,
|
||||
roles: state.roles,
|
||||
onUpdated: (PermanentOrderPosition updated) {
|
||||
BlocProvider.of<PermanentOrderBloc>(
|
||||
context,
|
||||
).add(PermanentOrderPositionUpdated(index, updated));
|
||||
},
|
||||
onRemoved: () {
|
||||
BlocProvider.of<PermanentOrderBloc>(
|
||||
context,
|
||||
).add(PermanentOrderPositionRemoved(index));
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PermanentDaysSelector extends StatelessWidget {
|
||||
const _PermanentDaysSelector({
|
||||
required this.selectedDays,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final List<String> selectedDays;
|
||||
final ValueChanged<int> onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const List<String> labelsShort = <String>[
|
||||
'S',
|
||||
'M',
|
||||
'T',
|
||||
'W',
|
||||
'T',
|
||||
'F',
|
||||
'S',
|
||||
];
|
||||
const List<String> labelsLong = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
children: List<Widget>.generate(labelsShort.length, (int index) {
|
||||
final bool isSelected = selectedDays.contains(labelsLong[index]);
|
||||
return GestureDetector(
|
||||
onTap: () => onToggle(index),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
labelsShort[index],
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: isSelected ? UiColors.white : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomActionButton extends StatelessWidget {
|
||||
const _BottomActionButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
});
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
top: UiConstants.space5,
|
||||
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: label,
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// A date picker field for the recurring order form.
|
||||
class RecurringOrderDatePicker extends StatefulWidget {
|
||||
/// Creates a [RecurringOrderDatePicker].
|
||||
const RecurringOrderDatePicker({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The label text to display above the field.
|
||||
final String label;
|
||||
|
||||
/// The currently selected date.
|
||||
final DateTime value;
|
||||
|
||||
/// Callback when a new date is selected.
|
||||
final ValueChanged<DateTime> onChanged;
|
||||
|
||||
@override
|
||||
State<RecurringOrderDatePicker> createState() =>
|
||||
_RecurringOrderDatePickerState();
|
||||
}
|
||||
|
||||
class _RecurringOrderDatePickerState extends State<RecurringOrderDatePicker> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(
|
||||
text: DateFormat('yyyy-MM-dd').format(widget.value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RecurringOrderDatePicker oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value != oldWidget.value) {
|
||||
_controller.text = DateFormat('yyyy-MM-dd').format(widget.value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiTextField(
|
||||
label: widget.label,
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
prefixIcon: UiIcons.calendar,
|
||||
onTap: () async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: widget.value,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (picked != null) {
|
||||
widget.onChanged(picked);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A text input for the order name in the recurring order form.
|
||||
class RecurringOrderEventNameInput extends StatefulWidget {
|
||||
const RecurringOrderEventNameInput({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
State<RecurringOrderEventNameInput> createState() =>
|
||||
_RecurringOrderEventNameInputState();
|
||||
}
|
||||
|
||||
class _RecurringOrderEventNameInputState
|
||||
extends State<RecurringOrderEventNameInput> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RecurringOrderEventNameInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value != _controller.text) {
|
||||
_controller.text = widget.value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiTextField(
|
||||
label: widget.label,
|
||||
controller: _controller,
|
||||
onChanged: widget.onChanged,
|
||||
hintText: 'Order name',
|
||||
prefixIcon: UiIcons.briefcase,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A header widget for the recurring order flow with a colored background.
|
||||
class RecurringOrderHeader extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderHeader].
|
||||
const RecurringOrderHeader({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onBack,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The title of the page.
|
||||
final String title;
|
||||
|
||||
/// The subtitle or description.
|
||||
final String subtitle;
|
||||
|
||||
/// Callback when the back button is pressed.
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + UiConstants.space5,
|
||||
bottom: UiConstants.space5,
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
),
|
||||
color: UiColors.primary,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: onBack,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.chevronLeft,
|
||||
color: UiColors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.headline3m.copyWith(color: UiColors.white),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: UiColors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../blocs/recurring_order_state.dart';
|
||||
|
||||
/// A card widget for editing a specific position in a recurring order.
|
||||
class RecurringOrderPositionCard extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderPositionCard].
|
||||
const RecurringOrderPositionCard({
|
||||
required this.index,
|
||||
required this.position,
|
||||
required this.isRemovable,
|
||||
required this.onUpdated,
|
||||
required this.onRemoved,
|
||||
required this.positionLabel,
|
||||
required this.roleLabel,
|
||||
required this.workersLabel,
|
||||
required this.startLabel,
|
||||
required this.endLabel,
|
||||
required this.lunchLabel,
|
||||
required this.roles,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The index of the position in the list.
|
||||
final int index;
|
||||
|
||||
/// The position entity data.
|
||||
final RecurringOrderPosition position;
|
||||
|
||||
/// Whether this position can be removed (usually if there's more than one).
|
||||
final bool isRemovable;
|
||||
|
||||
/// Callback when the position data is updated.
|
||||
final ValueChanged<RecurringOrderPosition> onUpdated;
|
||||
|
||||
/// Callback when the position is removed.
|
||||
final VoidCallback onRemoved;
|
||||
|
||||
/// Label for positions (e.g., "Position").
|
||||
final String positionLabel;
|
||||
|
||||
/// Label for the role selection.
|
||||
final String roleLabel;
|
||||
|
||||
/// Label for the worker count.
|
||||
final String workersLabel;
|
||||
|
||||
/// Label for the start time.
|
||||
final String startLabel;
|
||||
|
||||
/// Label for the end time.
|
||||
final String endLabel;
|
||||
|
||||
/// Label for the lunch break.
|
||||
final String lunchLabel;
|
||||
|
||||
/// Available roles for the selected vendor.
|
||||
final List<RecurringOrderRoleOption> roles;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'$positionLabel #${index + 1}',
|
||||
style: UiTypography.footnote1m.textSecondary,
|
||||
),
|
||||
if (isRemovable)
|
||||
GestureDetector(
|
||||
onTap: onRemoved,
|
||||
child: Text(
|
||||
t.client_create_order.one_time.remove,
|
||||
style: UiTypography.footnote1m.copyWith(
|
||||
color: UiColors.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Role (Dropdown)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
hint: Text(
|
||||
roleLabel,
|
||||
style: UiTypography.body2r.textPlaceholder,
|
||||
),
|
||||
value: position.role.isEmpty ? null : position.role,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (String? val) {
|
||||
if (val != null) {
|
||||
onUpdated(position.copyWith(role: val));
|
||||
}
|
||||
},
|
||||
items: _buildRoleItems(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Start/End/Workers Row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
// Start Time
|
||||
Expanded(
|
||||
child: _buildTimeInput(
|
||||
context: context,
|
||||
label: startLabel,
|
||||
value: position.startTime,
|
||||
onTap: () async {
|
||||
final TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.now(),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
onUpdated(
|
||||
position.copyWith(startTime: picked.format(context)),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
// End Time
|
||||
Expanded(
|
||||
child: _buildTimeInput(
|
||||
context: context,
|
||||
label: endLabel,
|
||||
value: position.endTime,
|
||||
onTap: () async {
|
||||
final TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.now(),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
onUpdated(
|
||||
position.copyWith(endTime: picked.format(context)),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
// Workers Count
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
workersLabel,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (position.count > 1) {
|
||||
onUpdated(
|
||||
position.copyWith(count: position.count - 1),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Icon(UiIcons.minus, size: 12),
|
||||
),
|
||||
Text(
|
||||
'${position.count}',
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
onUpdated(
|
||||
position.copyWith(count: position.count + 1),
|
||||
);
|
||||
},
|
||||
child: const Icon(UiIcons.add, size: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Lunch Break
|
||||
Text(lunchLabel, style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
value: position.lunchBreak,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (String? val) {
|
||||
if (val != null) {
|
||||
onUpdated(position.copyWith(lunchBreak: val));
|
||||
}
|
||||
},
|
||||
items: <String>[
|
||||
'NO_BREAK',
|
||||
'MIN_10',
|
||||
'MIN_15',
|
||||
'MIN_30',
|
||||
'MIN_45',
|
||||
'MIN_60',
|
||||
].map((String value) {
|
||||
final String label = switch (value) {
|
||||
'NO_BREAK' => 'No Break',
|
||||
'MIN_10' => '10 min (Paid)',
|
||||
'MIN_15' => '15 min (Paid)',
|
||||
'MIN_30' => '30 min (Unpaid)',
|
||||
'MIN_45' => '45 min (Unpaid)',
|
||||
'MIN_60' => '60 min (Unpaid)',
|
||||
_ => value,
|
||||
};
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimeInput({
|
||||
required BuildContext context,
|
||||
required String label,
|
||||
required String value,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(label, style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
value.isEmpty ? '--:--' : value,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
size: 14,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<DropdownMenuItem<String>> _buildRoleItems() {
|
||||
final List<DropdownMenuItem<String>> items = roles
|
||||
.map(
|
||||
(RecurringOrderRoleOption role) => DropdownMenuItem<String>(
|
||||
value: role.id,
|
||||
child: Text(
|
||||
'${role.name} - \$${role.costPerHour.toStringAsFixed(0)}',
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final bool hasSelected = roles.any((RecurringOrderRoleOption role) => role.id == position.role);
|
||||
if (position.role.isNotEmpty && !hasSelected) {
|
||||
items.add(
|
||||
DropdownMenuItem<String>(
|
||||
value: position.role,
|
||||
child: Text(
|
||||
position.role,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A header widget for sections in the recurring order form.
|
||||
class RecurringOrderSectionHeader extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderSectionHeader].
|
||||
const RecurringOrderSectionHeader({
|
||||
required this.title,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The title text for the section.
|
||||
final String title;
|
||||
|
||||
/// Optional label for an action button on the right.
|
||||
final String? actionLabel;
|
||||
|
||||
/// Callback when the action button is tapped.
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(title, style: UiTypography.headline4m.textPrimary),
|
||||
if (actionLabel != null && onAction != null)
|
||||
TextButton(
|
||||
onPressed: onAction,
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.add, size: 16, color: UiColors.primary),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
actionLabel!,
|
||||
style: UiTypography.body2m.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A view to display when a recurring order has been successfully created.
|
||||
class RecurringOrderSuccessView extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderSuccessView].
|
||||
const RecurringOrderSuccessView({
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.buttonLabel,
|
||||
required this.onDone,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The title of the success message.
|
||||
final String title;
|
||||
|
||||
/// The body of the success message.
|
||||
final String message;
|
||||
|
||||
/// Label for the completion button.
|
||||
final String buttonLabel;
|
||||
|
||||
/// Callback when the completion button is tapped.
|
||||
final VoidCallback onDone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: <Color>[UiColors.primary, UiColors.buttonPrimaryHover],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10),
|
||||
padding: const EdgeInsets.all(UiConstants.space8),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg * 1.5,
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, UiConstants.space2 + 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space16,
|
||||
height: UiConstants.space16,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.accent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.check,
|
||||
color: UiColors.black,
|
||||
size: UiConstants.space8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.headline2m.textPrimary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.textSecondary.copyWith(
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: buttonLabel,
|
||||
onPressed: onDone,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show Vendor;
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../blocs/recurring_order_bloc.dart';
|
||||
import '../../blocs/recurring_order_event.dart';
|
||||
import '../../blocs/recurring_order_state.dart';
|
||||
import 'recurring_order_date_picker.dart';
|
||||
import 'recurring_order_event_name_input.dart';
|
||||
import 'recurring_order_header.dart';
|
||||
import 'recurring_order_position_card.dart';
|
||||
import 'recurring_order_section_header.dart';
|
||||
import 'recurring_order_success_view.dart';
|
||||
|
||||
/// The main content of the Recurring Order page.
|
||||
class RecurringOrderView extends StatelessWidget {
|
||||
/// Creates a [RecurringOrderView].
|
||||
const RecurringOrderView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderRecurringEn labels =
|
||||
t.client_create_order.recurring;
|
||||
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return BlocConsumer<RecurringOrderBloc, RecurringOrderState>(
|
||||
listener: (BuildContext context, RecurringOrderState state) {
|
||||
if (state.status == RecurringOrderStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
final String message = state.errorMessage == 'placeholder'
|
||||
? labels.placeholder
|
||||
: translateErrorKey(state.errorMessage!);
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: message,
|
||||
type: UiSnackbarType.error,
|
||||
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, RecurringOrderState state) {
|
||||
if (state.status == RecurringOrderStatus.success) {
|
||||
return RecurringOrderSuccessView(
|
||||
title: labels.title,
|
||||
message: labels.subtitle,
|
||||
buttonLabel: oneTimeLabels.back_to_orders,
|
||||
onDone: () => Modular.to.pushNamedAndRemoveUntil(
|
||||
ClientPaths.orders,
|
||||
(_) => false,
|
||||
arguments: <String, dynamic>{
|
||||
'initialDate': state.startDate.toIso8601String(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.vendors.isEmpty &&
|
||||
state.status != RecurringOrderStatus.loading) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
RecurringOrderHeader(
|
||||
title: labels.title,
|
||||
subtitle: labels.subtitle,
|
||||
onBack: () => Modular.to.navigate(ClientPaths.createOrder),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.search,
|
||||
size: 64,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
'No Vendors Available',
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
'There are no staffing vendors associated with your account.',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
RecurringOrderHeader(
|
||||
title: labels.title,
|
||||
subtitle: labels.subtitle,
|
||||
onBack: () => Modular.to.navigate(ClientPaths.createOrder),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
_RecurringOrderForm(state: state),
|
||||
if (state.status == RecurringOrderStatus.loading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
_BottomActionButton(
|
||||
label: state.status == RecurringOrderStatus.loading
|
||||
? oneTimeLabels.creating
|
||||
: oneTimeLabels.create_order,
|
||||
isLoading: state.status == RecurringOrderStatus.loading,
|
||||
onPressed: state.isValid
|
||||
? () => BlocProvider.of<RecurringOrderBloc>(
|
||||
context,
|
||||
).add(const RecurringOrderSubmitted())
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecurringOrderForm extends StatelessWidget {
|
||||
const _RecurringOrderForm({required this.state});
|
||||
final RecurringOrderState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientCreateOrderRecurringEn labels =
|
||||
t.client_create_order.recurring;
|
||||
final TranslationsClientCreateOrderOneTimeEn oneTimeLabels =
|
||||
t.client_create_order.one_time;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
children: <Widget>[
|
||||
Text(
|
||||
labels.title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
RecurringOrderEventNameInput(
|
||||
label: 'ORDER NAME',
|
||||
value: state.eventName,
|
||||
onChanged: (String value) => BlocProvider.of<RecurringOrderBloc>(
|
||||
context,
|
||||
).add(RecurringOrderEventNameChanged(value)),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Vendor Selection
|
||||
Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Vendor>(
|
||||
isExpanded: true,
|
||||
value: state.selectedVendor,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (Vendor? vendor) {
|
||||
if (vendor != null) {
|
||||
BlocProvider.of<RecurringOrderBloc>(
|
||||
context,
|
||||
).add(RecurringOrderVendorChanged(vendor));
|
||||
}
|
||||
},
|
||||
items: state.vendors.map((Vendor vendor) {
|
||||
return DropdownMenuItem<Vendor>(
|
||||
value: vendor,
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
RecurringOrderDatePicker(
|
||||
label: 'Start Date',
|
||||
value: state.startDate,
|
||||
onChanged: (DateTime date) => BlocProvider.of<RecurringOrderBloc>(
|
||||
context,
|
||||
).add(RecurringOrderStartDateChanged(date)),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
RecurringOrderDatePicker(
|
||||
label: 'End Date',
|
||||
value: state.endDate,
|
||||
onChanged: (DateTime date) => BlocProvider.of<RecurringOrderBloc>(
|
||||
context,
|
||||
).add(RecurringOrderEndDateChanged(date)),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('Recurring Days', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_RecurringDaysSelector(
|
||||
selectedDays: state.recurringDays,
|
||||
onToggle: (int dayIndex) => BlocProvider.of<RecurringOrderBloc>(
|
||||
context,
|
||||
).add(RecurringOrderDayToggled(dayIndex)),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Text('HUB', style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<RecurringOrderHubOption>(
|
||||
isExpanded: true,
|
||||
value: state.selectedHub,
|
||||
icon: const Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onChanged: (RecurringOrderHubOption? hub) {
|
||||
if (hub != null) {
|
||||
BlocProvider.of<RecurringOrderBloc>(
|
||||
context,
|
||||
).add(RecurringOrderHubChanged(hub));
|
||||
}
|
||||
},
|
||||
items: state.hubs.map((RecurringOrderHubOption hub) {
|
||||
return DropdownMenuItem<RecurringOrderHubOption>(
|
||||
value: hub,
|
||||
child: Text(
|
||||
hub.name,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
RecurringOrderSectionHeader(
|
||||
title: oneTimeLabels.positions_title,
|
||||
actionLabel: oneTimeLabels.add_position,
|
||||
onAction: () => BlocProvider.of<RecurringOrderBloc>(
|
||||
context,
|
||||
).add(const RecurringOrderPositionAdded()),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Positions List
|
||||
...state.positions.asMap().entries.map((
|
||||
MapEntry<int, RecurringOrderPosition> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final RecurringOrderPosition position = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: RecurringOrderPositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
isRemovable: state.positions.length > 1,
|
||||
positionLabel: oneTimeLabels.positions_title,
|
||||
roleLabel: oneTimeLabels.select_role,
|
||||
workersLabel: oneTimeLabels.workers_label,
|
||||
startLabel: oneTimeLabels.start_label,
|
||||
endLabel: oneTimeLabels.end_label,
|
||||
lunchLabel: oneTimeLabels.lunch_break_label,
|
||||
roles: state.roles,
|
||||
onUpdated: (RecurringOrderPosition updated) {
|
||||
BlocProvider.of<RecurringOrderBloc>(
|
||||
context,
|
||||
).add(RecurringOrderPositionUpdated(index, updated));
|
||||
},
|
||||
onRemoved: () {
|
||||
BlocProvider.of<RecurringOrderBloc>(
|
||||
context,
|
||||
).add(RecurringOrderPositionRemoved(index));
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecurringDaysSelector extends StatelessWidget {
|
||||
const _RecurringDaysSelector({
|
||||
required this.selectedDays,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final List<String> selectedDays;
|
||||
final ValueChanged<int> onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const List<String> labelsShort = <String>[
|
||||
'S',
|
||||
'M',
|
||||
'T',
|
||||
'W',
|
||||
'T',
|
||||
'F',
|
||||
'S',
|
||||
];
|
||||
const List<String> labelsLong = <String>[
|
||||
'SUN',
|
||||
'MON',
|
||||
'TUE',
|
||||
'WED',
|
||||
'THU',
|
||||
'FRI',
|
||||
'SAT',
|
||||
];
|
||||
return Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
children: List<Widget>.generate(labelsShort.length, (int index) {
|
||||
final bool isSelected = selectedDays.contains(labelsLong[index]);
|
||||
return GestureDetector(
|
||||
onTap: () => onToggle(index),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
labelsShort[index],
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: isSelected ? UiColors.white : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomActionButton extends StatelessWidget {
|
||||
const _BottomActionButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
});
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
top: UiConstants.space5,
|
||||
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: label,
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ class HomeRepositoryImpl
|
||||
address: staff.addres,
|
||||
avatar: staff.photoUrl,
|
||||
),
|
||||
ownerId: session?.ownerId,
|
||||
ownerId: staff.ownerId,
|
||||
);
|
||||
StaffSessionStore.instance.setSession(updatedSession);
|
||||
|
||||
|
||||
@@ -49,13 +49,17 @@ class WorkerHomePage extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
buildWhen: (previous, current) => previous.staffName != current.staffName,
|
||||
buildWhen: (previous, current) =>
|
||||
previous.staffName != current.staffName,
|
||||
builder: (context, state) {
|
||||
return HomeHeader(userName: state.staffName);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space4,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
@@ -67,7 +71,7 @@ class WorkerHomePage extends StatelessWidget {
|
||||
return PlaceholderBanner(
|
||||
title: bannersI18n.complete_profile_title,
|
||||
subtitle: bannersI18n.complete_profile_subtitle,
|
||||
bg: UiColors.bgHighlight,
|
||||
bg: UiColors.primaryInverse,
|
||||
accent: UiColors.primary,
|
||||
onTap: () {
|
||||
Modular.to.toProfile();
|
||||
@@ -135,7 +139,8 @@ class WorkerHomePage extends StatelessWidget {
|
||||
EmptyStateWidget(
|
||||
message: emptyI18n.no_shifts_today,
|
||||
actionLink: emptyI18n.find_shifts_cta,
|
||||
onAction: () => Modular.to.toShifts(initialTab: 'find'),
|
||||
onAction: () =>
|
||||
Modular.to.toShifts(initialTab: 'find'),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
@@ -183,9 +188,7 @@ class WorkerHomePage extends StatelessWidget {
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Recommended Shifts
|
||||
SectionHeader(
|
||||
title: sectionsI18n.recommended_for_you,
|
||||
),
|
||||
SectionHeader(title: sectionsI18n.recommended_for_you),
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
if (state.recommendedShifts.isEmpty) {
|
||||
@@ -201,7 +204,8 @@ class WorkerHomePage extends StatelessWidget {
|
||||
clipBehavior: Clip.none,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: UiConstants.space3),
|
||||
right: UiConstants.space3,
|
||||
),
|
||||
child: RecommendedShiftCard(
|
||||
shift: state.recommendedShifts[index],
|
||||
),
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
|
||||
|
||||
/// Banner widget for placeholder actions, using design system tokens.
|
||||
class PlaceholderBanner extends StatelessWidget {
|
||||
/// Banner title
|
||||
final String title;
|
||||
|
||||
/// Banner subtitle
|
||||
final String subtitle;
|
||||
|
||||
/// Banner background color
|
||||
final Color bg;
|
||||
|
||||
/// Banner accent color
|
||||
final Color accent;
|
||||
|
||||
/// Optional tap callback
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Creates a [PlaceholderBanner].
|
||||
const PlaceholderBanner({super.key, required this.title, required this.subtitle, required this.bg, required this.accent, this.onTap});
|
||||
const PlaceholderBanner({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.bg,
|
||||
required this.accent,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -29,7 +38,7 @@ class PlaceholderBanner extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.3)),
|
||||
border: Border.all(color: accent, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -41,7 +50,11 @@ class PlaceholderBanner extends StatelessWidget {
|
||||
color: UiColors.bgBanner,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(UiIcons.sparkles, color: accent, size: UiConstants.space5),
|
||||
child: Icon(
|
||||
UiIcons.sparkles,
|
||||
color: accent,
|
||||
size: UiConstants.space5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
@@ -50,12 +63,9 @@ class PlaceholderBanner extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body1b,
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: UiTypography.body3r.copyWith(color: UiColors.mutedForeground),
|
||||
style: UiTypography.body1b.copyWith(color: accent),
|
||||
),
|
||||
Text(subtitle, style: UiTypography.body3r.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,14 +8,11 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../blocs/profile_cubit.dart';
|
||||
import '../blocs/profile_state.dart';
|
||||
import '../widgets/language_selector_bottom_sheet.dart';
|
||||
import '../widgets/logout_button.dart';
|
||||
import '../widgets/profile_header.dart';
|
||||
import '../widgets/profile_menu_grid.dart';
|
||||
import '../widgets/profile_menu_item.dart';
|
||||
import '../widgets/reliability_score_bar.dart';
|
||||
import '../widgets/reliability_stats_card.dart';
|
||||
import '../widgets/section_title.dart';
|
||||
import '../widgets/sections/index.dart';
|
||||
|
||||
/// The main Staff Profile page.
|
||||
///
|
||||
@@ -49,7 +46,6 @@ class StaffProfilePage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = Translations.of(context).staff.profile;
|
||||
final ProfileCubit cubit = Modular.get<ProfileCubit>();
|
||||
|
||||
// Load profile data on first build
|
||||
@@ -60,7 +56,7 @@ class StaffProfilePage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
body: BlocConsumer<ProfileCubit, ProfileState>(
|
||||
bloc: cubit,
|
||||
listener: (context, state) {
|
||||
listener: (BuildContext context, ProfileState state) {
|
||||
if (state.status == ProfileStatus.signedOut) {
|
||||
Modular.to.toGetStartedPage();
|
||||
} else if (state.status == ProfileStatus.error &&
|
||||
@@ -72,7 +68,7 @@ class StaffProfilePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
builder: (BuildContext context, ProfileState state) {
|
||||
// Show loading spinner if status is loading
|
||||
if (state.status == ProfileStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -95,7 +91,7 @@ class StaffProfilePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final profile = state.profile;
|
||||
final Staff? profile = state.profile;
|
||||
if (profile == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -103,7 +99,7 @@ class StaffProfilePage extends StatelessWidget {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space16),
|
||||
child: Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
ProfileHeader(
|
||||
fullName: profile.name,
|
||||
level: _mapStatusToLevel(profile.status),
|
||||
@@ -117,7 +113,7 @@ class StaffProfilePage extends StatelessWidget {
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
ReliabilityStatsCard(
|
||||
totalShifts: profile.totalShifts,
|
||||
averageRating: profile.averageRating,
|
||||
@@ -130,86 +126,15 @@ class StaffProfilePage extends StatelessWidget {
|
||||
reliabilityScore: profile.reliabilityScore,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
SectionTitle(i18n.sections.onboarding),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
|
||||
children: [
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.user,
|
||||
label: i18n.menu_items.personal_info,
|
||||
onTap: () => Modular.to.toPersonalInfo(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.phone,
|
||||
label: i18n.menu_items.emergency_contact,
|
||||
onTap: () => Modular.to.toEmergencyContact(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.briefcase,
|
||||
label: i18n.menu_items.experience,
|
||||
onTap: () => Modular.to.toExperience(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const OnboardingSection(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionTitle(i18n.sections.compliance),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
children: [
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.file,
|
||||
label: i18n.menu_items.tax_forms,
|
||||
onTap: () => Modular.to.toTaxForms(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const ComplianceSection(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
SectionTitle(i18n.sections.finance),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
children: [
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.building,
|
||||
label: i18n.menu_items.bank_account,
|
||||
onTap: () => Modular.to.toBankAccount(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.creditCard,
|
||||
label: i18n.menu_items.payments,
|
||||
onTap: () => Modular.to.toPayments(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.clock,
|
||||
label: i18n.menu_items.timecard,
|
||||
onTap: () => Modular.to.toTimeCard(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const FinanceSection(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
SectionTitle(
|
||||
i18n.header.title.contains("Perfil") ? "Ajustes" : "Settings",
|
||||
),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
children: [
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.globe,
|
||||
label: i18n.header.title.contains("Perfil") ? "Idioma" : "Language",
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => const LanguageSelectorBottomSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SupportSection(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
const SettingsSection(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
LogoutButton(
|
||||
onTap: () => _onSignOut(cubit, state),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../profile_menu_grid.dart';
|
||||
import '../profile_menu_item.dart';
|
||||
import '../section_title.dart';
|
||||
|
||||
/// Widget displaying the compliance section of the staff profile.
|
||||
///
|
||||
/// This section contains menu items for tax forms and other compliance-related documents.
|
||||
class ComplianceSection extends StatelessWidget {
|
||||
/// Creates a [ComplianceSection].
|
||||
const ComplianceSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SectionTitle(i18n.sections.compliance),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
children: <Widget>[
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.file,
|
||||
label: i18n.menu_items.tax_forms,
|
||||
onTap: () => Modular.to.toTaxForms(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../profile_menu_grid.dart';
|
||||
import '../profile_menu_item.dart';
|
||||
import '../section_title.dart';
|
||||
|
||||
/// Widget displaying the finance section of the staff profile.
|
||||
///
|
||||
/// This section contains menu items for bank account, payments, and timecard information.
|
||||
class FinanceSection extends StatelessWidget {
|
||||
/// Creates a [FinanceSection].
|
||||
const FinanceSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
SectionTitle(i18n.sections.finance),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
children: <Widget>[
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.building,
|
||||
label: i18n.menu_items.bank_account,
|
||||
onTap: () => Modular.to.toBankAccount(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.creditCard,
|
||||
label: i18n.menu_items.payments,
|
||||
onTap: () => Modular.to.toPayments(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.clock,
|
||||
label: i18n.menu_items.timecard,
|
||||
onTap: () => Modular.to.toTimeCard(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export 'compliance_section.dart';
|
||||
export 'finance_section.dart';
|
||||
export 'onboarding_section.dart';
|
||||
export 'settings_section.dart';
|
||||
export 'support_section.dart';
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../profile_menu_grid.dart';
|
||||
import '../profile_menu_item.dart';
|
||||
import '../section_title.dart';
|
||||
|
||||
/// Widget displaying the onboarding section of the staff profile.
|
||||
///
|
||||
/// This section contains menu items for personal information, emergency contact,
|
||||
/// and work experience setup.
|
||||
class OnboardingSection extends StatelessWidget {
|
||||
/// Creates an [OnboardingSection].
|
||||
const OnboardingSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
SectionTitle(i18n.sections.onboarding),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
children: <Widget>[
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.user,
|
||||
label: i18n.menu_items.personal_info,
|
||||
onTap: () => Modular.to.toPersonalInfo(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.phone,
|
||||
label: i18n.menu_items.emergency_contact,
|
||||
onTap: () => Modular.to.toEmergencyContact(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.briefcase,
|
||||
label: i18n.menu_items.experience,
|
||||
onTap: () => Modular.to.toExperience(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../language_selector_bottom_sheet.dart';
|
||||
import '../profile_menu_grid.dart';
|
||||
import '../profile_menu_item.dart';
|
||||
import '../section_title.dart';
|
||||
|
||||
/// Widget displaying the settings section of the staff profile.
|
||||
///
|
||||
/// This section contains menu items for language selection.
|
||||
class SettingsSection extends StatelessWidget {
|
||||
/// Creates a [SettingsSection].
|
||||
const SettingsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffProfileEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.profile;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
children: <Widget>[
|
||||
SectionTitle(i18n.sections.settings),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
children: <Widget>[
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.globe,
|
||||
label: i18n.menu_items.language,
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (BuildContext context) =>
|
||||
const LanguageSelectorBottomSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../profile_menu_grid.dart';
|
||||
import '../profile_menu_item.dart';
|
||||
import '../section_title.dart';
|
||||
|
||||
/// Widget displaying the support section of the staff profile.
|
||||
///
|
||||
/// This section contains menu items for FAQs and privacy & security settings.
|
||||
class SupportSection extends StatelessWidget {
|
||||
/// Creates a [SupportSection].
|
||||
const SupportSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffProfileEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.profile;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SectionTitle(i18n.sections.support),
|
||||
ProfileMenuGrid(
|
||||
crossAxisCount: 3,
|
||||
children: <Widget>[
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.helpCircle,
|
||||
label: i18n.menu_items.faqs,
|
||||
onTap: () => Modular.to.toFaqs(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.shield,
|
||||
label: i18n.menu_items.privacy_security,
|
||||
onTap: () => Modular.to.toPrivacySecurity(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
[
|
||||
{
|
||||
"category": "Getting Started",
|
||||
"questions": [
|
||||
{
|
||||
"q": "How do I apply for shifts?",
|
||||
"a": "Browse available shifts on the Shifts tab and tap \"Accept\" on any shift that interests you. Once confirmed, you'll receive all the details you need."
|
||||
},
|
||||
{
|
||||
"q": "How do I get paid?",
|
||||
"a": "Payments are processed weekly via direct deposit to your linked bank account. You can view your earnings in the Payments section."
|
||||
},
|
||||
{
|
||||
"q": "What if I need to cancel a shift?",
|
||||
"a": "You can cancel a shift up to 24 hours before it starts without penalty. Late cancellations may affect your reliability score."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Shifts & Work",
|
||||
"questions": [
|
||||
{
|
||||
"q": "How do I clock in?",
|
||||
"a": "Use the Clock In feature on the home screen when you arrive at your shift. Make sure location services are enabled for verification."
|
||||
},
|
||||
{
|
||||
"q": "What should I wear?",
|
||||
"a": "Check the shift details for dress code requirements. You can manage your wardrobe in the Attire section of your profile."
|
||||
},
|
||||
{
|
||||
"q": "Who do I contact if I'm running late?",
|
||||
"a": "Use the \"Running Late\" feature in the app to notify the client. You can also message the shift manager directly."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Payments & Earnings",
|
||||
"questions": [
|
||||
{
|
||||
"q": "When do I get paid?",
|
||||
"a": "Payments are processed every Friday for shifts completed the previous week. Funds typically arrive within 1-2 business days."
|
||||
},
|
||||
{
|
||||
"q": "How do I update my bank account?",
|
||||
"a": "Go to Profile > Finance > Bank Account to add or update your banking information."
|
||||
},
|
||||
{
|
||||
"q": "Where can I find my tax documents?",
|
||||
"a": "Tax documents (1099) are available in Profile > Compliance > Tax Documents by January 31st each year."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../domain/entities/faq_category.dart';
|
||||
import '../../domain/entities/faq_item.dart';
|
||||
import '../../domain/repositories/faqs_repository_interface.dart';
|
||||
|
||||
/// Data layer implementation of FAQs repository
|
||||
///
|
||||
/// Handles loading FAQs from app assets (JSON file)
|
||||
class FaqsRepositoryImpl implements FaqsRepositoryInterface {
|
||||
/// Private cache for FAQs to avoid reloading from assets multiple times
|
||||
List<FaqCategory>? _cachedFaqs;
|
||||
|
||||
@override
|
||||
Future<List<FaqCategory>> getFaqs() async {
|
||||
try {
|
||||
// Return cached FAQs if available
|
||||
if (_cachedFaqs != null) {
|
||||
return _cachedFaqs!;
|
||||
}
|
||||
|
||||
// Load FAQs from JSON asset
|
||||
final String faqsJson = await rootBundle.loadString(
|
||||
'packages/staff_faqs/lib/src/assets/faqs/faqs.json',
|
||||
);
|
||||
|
||||
// Parse JSON
|
||||
final List<dynamic> decoded = jsonDecode(faqsJson) as List<dynamic>;
|
||||
|
||||
// Convert to domain entities
|
||||
_cachedFaqs = decoded.map((dynamic item) {
|
||||
final Map<String, dynamic> category = item as Map<String, dynamic>;
|
||||
final String categoryName = category['category'] as String;
|
||||
final List<dynamic> questionsData =
|
||||
category['questions'] as List<dynamic>;
|
||||
|
||||
final List<FaqItem> questions = questionsData.map((dynamic q) {
|
||||
final Map<String, dynamic> questionMap = q as Map<String, dynamic>;
|
||||
return FaqItem(
|
||||
question: questionMap['q'] as String,
|
||||
answer: questionMap['a'] as String,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return FaqCategory(
|
||||
category: categoryName,
|
||||
questions: questions,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return _cachedFaqs!;
|
||||
} catch (e) {
|
||||
// Return empty list on error
|
||||
return <FaqCategory>[];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FaqCategory>> searchFaqs(String query) async {
|
||||
try {
|
||||
// Get all FAQs first
|
||||
final List<FaqCategory> allFaqs = await getFaqs();
|
||||
|
||||
if (query.isEmpty) {
|
||||
return allFaqs;
|
||||
}
|
||||
|
||||
final String lowerQuery = query.toLowerCase();
|
||||
|
||||
// Filter categories based on matching questions
|
||||
final List<FaqCategory> filtered = allFaqs
|
||||
.map((FaqCategory category) {
|
||||
// Filter questions that match the query
|
||||
final List<FaqItem> matchingQuestions =
|
||||
category.questions.where((FaqItem item) {
|
||||
final String questionLower = item.question.toLowerCase();
|
||||
final String answerLower = item.answer.toLowerCase();
|
||||
return questionLower.contains(lowerQuery) ||
|
||||
answerLower.contains(lowerQuery);
|
||||
}).toList();
|
||||
|
||||
// Only include category if it has matching questions
|
||||
if (matchingQuestions.isNotEmpty) {
|
||||
return FaqCategory(
|
||||
category: category.category,
|
||||
questions: matchingQuestions,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.whereType<FaqCategory>()
|
||||
.toList();
|
||||
|
||||
return filtered;
|
||||
} catch (e) {
|
||||
return <FaqCategory>[];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'faq_item.dart';
|
||||
|
||||
/// Entity representing an FAQ category with its questions
|
||||
class FaqCategory extends Equatable {
|
||||
/// The category name (e.g., "Getting Started", "Shifts & Work")
|
||||
final String category;
|
||||
|
||||
/// List of FAQ items in this category
|
||||
final List<FaqItem> questions;
|
||||
|
||||
const FaqCategory({
|
||||
required this.category,
|
||||
required this.questions,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[category, questions];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Entity representing a single FAQ question and answer
|
||||
class FaqItem extends Equatable {
|
||||
/// The question text
|
||||
final String question;
|
||||
|
||||
/// The answer text
|
||||
final String answer;
|
||||
|
||||
const FaqItem({
|
||||
required this.question,
|
||||
required this.answer,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[question, answer];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import '../entities/faq_category.dart';
|
||||
|
||||
/// Interface for FAQs repository operations
|
||||
abstract class FaqsRepositoryInterface {
|
||||
/// Fetch all FAQ categories with their questions
|
||||
Future<List<FaqCategory>> getFaqs();
|
||||
|
||||
/// Search FAQs by query string
|
||||
/// Returns categories that contain matching questions
|
||||
Future<List<FaqCategory>> searchFaqs(String query);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../entities/faq_category.dart';
|
||||
import '../repositories/faqs_repository_interface.dart';
|
||||
|
||||
/// Use case to retrieve all FAQs
|
||||
class GetFaqsUseCase {
|
||||
final FaqsRepositoryInterface _repository;
|
||||
|
||||
GetFaqsUseCase(this._repository);
|
||||
|
||||
/// Execute the use case to get all FAQ categories
|
||||
Future<List<FaqCategory>> call() async {
|
||||
try {
|
||||
return await _repository.getFaqs();
|
||||
} catch (e) {
|
||||
// Return empty list on error
|
||||
return <FaqCategory>[];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import '../entities/faq_category.dart';
|
||||
import '../repositories/faqs_repository_interface.dart';
|
||||
|
||||
/// Parameters for search FAQs use case
|
||||
class SearchFaqsParams {
|
||||
/// Search query string
|
||||
final String query;
|
||||
|
||||
SearchFaqsParams({required this.query});
|
||||
}
|
||||
|
||||
/// Use case to search FAQs by query
|
||||
class SearchFaqsUseCase {
|
||||
final FaqsRepositoryInterface _repository;
|
||||
|
||||
SearchFaqsUseCase(this._repository);
|
||||
|
||||
/// Execute the use case to search FAQs
|
||||
Future<List<FaqCategory>> call(SearchFaqsParams params) async {
|
||||
try {
|
||||
return await _repository.searchFaqs(params.query);
|
||||
} catch (e) {
|
||||
// Return empty list on error
|
||||
return <FaqCategory>[];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../domain/entities/faq_category.dart';
|
||||
import '../../domain/usecases/get_faqs_usecase.dart';
|
||||
import '../../domain/usecases/search_faqs_usecase.dart';
|
||||
|
||||
part 'faqs_event.dart';
|
||||
part 'faqs_state.dart';
|
||||
|
||||
/// BLoC managing FAQs state
|
||||
class FaqsBloc extends Bloc<FaqsEvent, FaqsState> {
|
||||
final GetFaqsUseCase _getFaqsUseCase;
|
||||
final SearchFaqsUseCase _searchFaqsUseCase;
|
||||
|
||||
FaqsBloc({
|
||||
required GetFaqsUseCase getFaqsUseCase,
|
||||
required SearchFaqsUseCase searchFaqsUseCase,
|
||||
}) : _getFaqsUseCase = getFaqsUseCase,
|
||||
_searchFaqsUseCase = searchFaqsUseCase,
|
||||
super(const FaqsState()) {
|
||||
on<FetchFaqsEvent>(_onFetchFaqs);
|
||||
on<SearchFaqsEvent>(_onSearchFaqs);
|
||||
}
|
||||
|
||||
Future<void> _onFetchFaqs(
|
||||
FetchFaqsEvent event,
|
||||
Emitter<FaqsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true, error: null));
|
||||
|
||||
try {
|
||||
final List<FaqCategory> categories = await _getFaqsUseCase.call();
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
categories: categories,
|
||||
searchQuery: '',
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to load FAQs',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSearchFaqs(
|
||||
SearchFaqsEvent event,
|
||||
Emitter<FaqsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true, error: null, searchQuery: event.query));
|
||||
|
||||
try {
|
||||
final List<FaqCategory> results = await _searchFaqsUseCase.call(
|
||||
SearchFaqsParams(query: event.query),
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
categories: results,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to search FAQs',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
part of 'faqs_bloc.dart';
|
||||
|
||||
/// Base class for FAQs BLoC events
|
||||
abstract class FaqsEvent extends Equatable {
|
||||
const FaqsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Event to fetch all FAQs
|
||||
class FetchFaqsEvent extends FaqsEvent {
|
||||
const FetchFaqsEvent();
|
||||
}
|
||||
|
||||
/// Event to search FAQs by query
|
||||
class SearchFaqsEvent extends FaqsEvent {
|
||||
/// Search query string
|
||||
final String query;
|
||||
|
||||
const SearchFaqsEvent({required this.query});
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[query];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
part of 'faqs_bloc.dart';
|
||||
|
||||
/// State for FAQs BLoC
|
||||
class FaqsState extends Equatable {
|
||||
/// List of FAQ categories currently displayed
|
||||
final List<FaqCategory> categories;
|
||||
|
||||
/// Whether FAQs are currently loading
|
||||
final bool isLoading;
|
||||
|
||||
/// Current search query
|
||||
final String searchQuery;
|
||||
|
||||
/// Error message, if any
|
||||
final String? error;
|
||||
|
||||
const FaqsState({
|
||||
this.categories = const <FaqCategory>[],
|
||||
this.isLoading = false,
|
||||
this.searchQuery = '',
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// Create a copy with optional field overrides
|
||||
FaqsState copyWith({
|
||||
List<FaqCategory>? categories,
|
||||
bool? isLoading,
|
||||
String? searchQuery,
|
||||
String? error,
|
||||
}) {
|
||||
return FaqsState(
|
||||
categories: categories ?? this.categories,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
categories,
|
||||
isLoading,
|
||||
searchQuery,
|
||||
error,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
import '../blocs/faqs_bloc.dart';
|
||||
import '../widgets/faqs_widget.dart';
|
||||
|
||||
/// Page displaying frequently asked questions
|
||||
class FaqsPage extends StatelessWidget {
|
||||
const FaqsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: t.staff_faqs.title,
|
||||
showBackButton: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Container(color: UiColors.border, height: 1),
|
||||
),
|
||||
),
|
||||
body: BlocProvider<FaqsBloc>(
|
||||
create: (BuildContext context) =>
|
||||
Modular.get<FaqsBloc>()..add(const FetchFaqsEvent()),
|
||||
child: const Stack(children: <Widget>[FaqsWidget()]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:staff_faqs/src/presentation/blocs/faqs_bloc.dart';
|
||||
|
||||
/// Widget displaying FAQs with search functionality and accordion items
|
||||
class FaqsWidget extends StatefulWidget {
|
||||
const FaqsWidget({super.key});
|
||||
|
||||
@override
|
||||
State<FaqsWidget> createState() => _FaqsWidgetState();
|
||||
}
|
||||
|
||||
class _FaqsWidgetState extends State<FaqsWidget> {
|
||||
late TextEditingController _searchController;
|
||||
final Map<String, bool> _openItems = <String, bool>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleItem(String key) {
|
||||
setState(() {
|
||||
_openItems[key] = !(_openItems[key] ?? false);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
if (value.isEmpty) {
|
||||
context.read<FaqsBloc>().add(const FetchFaqsEvent());
|
||||
} else {
|
||||
context.read<FaqsBloc>().add(SearchFaqsEvent(query: value));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<FaqsBloc, FaqsState>(
|
||||
builder: (BuildContext context, FaqsState state) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 100),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
// Search Bar
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: t.staff_faqs.search_placeholder,
|
||||
hintStyle: const TextStyle(color: UiColors.textPlaceholder),
|
||||
prefixIcon: const Icon(
|
||||
UiIcons.search,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// FAQ List or Empty State
|
||||
if (state.isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 48),
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
else if (state.categories.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.helpCircle,
|
||||
size: 48,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
t.staff_faqs.no_results,
|
||||
style: const TextStyle(color: UiColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
...state.categories.asMap().entries.map((
|
||||
MapEntry<int, dynamic> entry,
|
||||
) {
|
||||
final int catIndex = entry.key;
|
||||
final dynamic categoryItem = entry.value;
|
||||
final String categoryName = categoryItem.category;
|
||||
final List<dynamic> questions = categoryItem.questions;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
categoryName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...questions.asMap().entries.map((
|
||||
MapEntry<int, dynamic> qEntry,
|
||||
) {
|
||||
final int qIndex = qEntry.key;
|
||||
final dynamic questionItem = qEntry.value;
|
||||
final String key = '$catIndex-$qIndex';
|
||||
final bool isOpen = _openItems[key] ?? false;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
InkWell(
|
||||
onTap: () => _toggleItem(key),
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
questionItem.question,
|
||||
style: UiTypography.body1r,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
isOpen
|
||||
? UiIcons.chevronUp
|
||||
: UiIcons.chevronDown,
|
||||
color: UiColors.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOpen)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
16,
|
||||
),
|
||||
child: Text(
|
||||
questionItem.answer,
|
||||
style: UiTypography.body1r.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'data/repositories_impl/faqs_repository_impl.dart';
|
||||
import 'domain/repositories/faqs_repository_interface.dart';
|
||||
import 'domain/usecases/get_faqs_usecase.dart';
|
||||
import 'domain/usecases/search_faqs_usecase.dart';
|
||||
import 'presentation/blocs/faqs_bloc.dart';
|
||||
import 'presentation/pages/faqs_page.dart';
|
||||
|
||||
/// Module for FAQs feature
|
||||
///
|
||||
/// Provides:
|
||||
/// - Dependency injection for repositories, use cases, and BLoCs
|
||||
/// - Route definitions delegated to core routing
|
||||
class FaqsModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.addSingleton<FaqsRepositoryInterface>(
|
||||
() => FaqsRepositoryImpl(),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.addSingleton(
|
||||
() => GetFaqsUseCase(
|
||||
i<FaqsRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
i.addSingleton(
|
||||
() => SearchFaqsUseCase(
|
||||
i<FaqsRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
|
||||
// BLoC
|
||||
i.add(
|
||||
() => FaqsBloc(
|
||||
getFaqsUseCase: i<GetFaqsUseCase>(),
|
||||
searchFaqsUseCase: i<SearchFaqsUseCase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child(
|
||||
StaffPaths.childRoute(StaffPaths.faqs, StaffPaths.faqs),
|
||||
child: (_) => const FaqsPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
library staff_faqs;
|
||||
|
||||
export 'src/staff_faqs_module.dart';
|
||||
export 'src/presentation/pages/faqs_page.dart';
|
||||
@@ -0,0 +1,29 @@
|
||||
name: staff_faqs
|
||||
description: Frequently Asked Questions feature for staff application.
|
||||
version: 0.0.1
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.10.0 <4.0.0'
|
||||
flutter: ">=3.0.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
|
||||
# Architecture Packages
|
||||
krow_core:
|
||||
path: ../../../../../core
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
core_localization:
|
||||
path: ../../../../../core_localization
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- lib/src/assets/faqs/
|
||||
@@ -0,0 +1,119 @@
|
||||
PRIVACY POLICY
|
||||
|
||||
Effective Date: February 18, 2026
|
||||
|
||||
1. INTRODUCTION
|
||||
|
||||
Krow Workforce ("we," "us," "our," or "the App") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, disclose, and otherwise process your personal information through our mobile application and related services.
|
||||
|
||||
2. INFORMATION WE COLLECT
|
||||
|
||||
2.1 Information You Provide Directly:
|
||||
- Account information: name, email address, phone number, password
|
||||
- Profile information: photo, bio, skills, experience, certifications
|
||||
- Location data: work location preferences and current location (when enabled)
|
||||
- Payment information: bank account details, tax identification numbers
|
||||
- Communication data: messages, support inquiries, feedback
|
||||
|
||||
2.2 Information Collected Automatically:
|
||||
- Device information: device type, operating system, device identifiers
|
||||
- Usage data: features accessed, actions taken, time and duration of activities
|
||||
- Log data: IP address, browser type, pages visited, errors encountered
|
||||
- Location data: approximate location based on IP address (always)
|
||||
- Precise location: only when Location Sharing is enabled
|
||||
|
||||
2.3 Information from Third Parties:
|
||||
- Background check services: verification results
|
||||
- Banking partners: account verification information
|
||||
- Payment processors: transaction information
|
||||
|
||||
3. HOW WE USE YOUR INFORMATION
|
||||
|
||||
We use your information to:
|
||||
- Create and maintain your account
|
||||
- Process payments and verify employment eligibility
|
||||
- Improve and optimize our services
|
||||
- Send you important notifications and updates
|
||||
- Provide customer support
|
||||
- Prevent fraud and ensure security
|
||||
- Comply with legal obligations
|
||||
- Conduct analytics and research
|
||||
- Match you with appropriate work opportunities
|
||||
- Communicate promotional offers (with your consent)
|
||||
|
||||
4. LOCATION DATA & PRIVACY SETTINGS
|
||||
|
||||
4.1 Location Sharing:
|
||||
You can control location sharing through Privacy Settings:
|
||||
- Disabled (default): Your approximate location is based on IP address only
|
||||
- Enabled: Precise location data is collected for better job matching
|
||||
|
||||
4.2 Your Control:
|
||||
You may enable or disable precise location sharing at any time in the Privacy & Security section of your profile.
|
||||
|
||||
5. DATA RETENTION
|
||||
|
||||
We retain your personal information for as long as:
|
||||
- Your account is active, plus
|
||||
- An additional period as required by law or for business purposes
|
||||
|
||||
You may request deletion of your account and associated data by contacting support@krow.com.
|
||||
|
||||
6. DATA SECURITY
|
||||
|
||||
We implement appropriate technical and organizational measures to protect your personal information from unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the internet is 100% secure.
|
||||
|
||||
7. SHARING OF INFORMATION
|
||||
|
||||
We do not sell your personal information. We may share information with:
|
||||
- Service providers and contractors: who process data on our behalf
|
||||
- Employers and clients: limited information needed for job matching
|
||||
- Legal authorities: when required by law
|
||||
- Business partners: with your explicit consent
|
||||
- Other users: your name, skills, and ratings (as needed for job matching)
|
||||
|
||||
8. YOUR PRIVACY RIGHTS
|
||||
|
||||
8.1 Access and Correction:
|
||||
You have the right to access, review, and request correction of your personal information.
|
||||
|
||||
8.2 Data Portability:
|
||||
You may request a copy of your personal data in a portable format.
|
||||
|
||||
8.3 Deletion:
|
||||
You may request deletion of your account and personal information, subject to legal obligations.
|
||||
|
||||
8.4 Opt-Out:
|
||||
You may opt out of marketing communications and certain data processing activities.
|
||||
|
||||
9. CHILDREN'S PRIVACY
|
||||
|
||||
Our App is not intended for individuals under 18 years of age. We do not knowingly collect personal information from children. If we become aware that we have collected information from a child, we will take steps to delete such information immediately.
|
||||
|
||||
10. THIRD-PARTY LINKS
|
||||
|
||||
Our App may contain links to third-party websites. We are not responsible for the privacy practices of these external sites. We encourage you to review their privacy policies.
|
||||
|
||||
11. INTERNATIONAL DATA TRANSFERS
|
||||
|
||||
Your information may be transferred to, stored in, and processed in countries other than your country of residence. These countries may have data protection laws different from your home country.
|
||||
|
||||
12. CHANGES TO THIS POLICY
|
||||
|
||||
We may update this Privacy Policy from time to time. We will notify you of significant changes via email or through the App. Your continued use of the App constitutes your acceptance of the updated Privacy Policy.
|
||||
|
||||
13. CONTACT US
|
||||
|
||||
If you have questions about this Privacy Policy or your personal information, please contact us at:
|
||||
|
||||
Email: privacy@krow.com
|
||||
Address: Krow Workforce, [Company Address]
|
||||
Phone: [Support Phone Number]
|
||||
|
||||
14. CALIFORNIA PRIVACY RIGHTS (CCPA)
|
||||
|
||||
If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA). Please visit our CCPA Rights page or contact privacy@krow.com for more information.
|
||||
|
||||
15. EUROPEAN PRIVACY RIGHTS (GDPR)
|
||||
|
||||
If you are in the European Union, you have rights under the General Data Protection Regulation (GDPR). These include the right to access, rectification, erasure, and data portability. Contact privacy@krow.com to exercise these rights.
|
||||
@@ -0,0 +1,61 @@
|
||||
TERMS OF SERVICE
|
||||
|
||||
Effective Date: February 18, 2026
|
||||
|
||||
1. ACCEPTANCE OF TERMS
|
||||
|
||||
By accessing and using the Krow Workforce application ("the App"), you accept and agree to be bound by the terms and provisions of this agreement. If you do not agree to abide by the above, please do not use this service.
|
||||
|
||||
2. USE LICENSE
|
||||
|
||||
Permission is granted to temporarily download one copy of the materials (information or software) on Krow Workforce's App for personal, non-commercial transitory viewing only. This is the grant of a license, not a transfer of title, and under this license you may not:
|
||||
|
||||
a) Modifying or copying the materials
|
||||
b) Using the materials for any commercial purpose or for any public display
|
||||
c) Attempting to reverse engineer, disassemble, or decompile any software contained on the App
|
||||
d) Removing any copyright or other proprietary notations from the materials
|
||||
e) Transferring the materials to another person or "mirroring" the materials on any other server
|
||||
|
||||
3. DISCLAIMER
|
||||
|
||||
The materials on Krow Workforce's App are provided on an "as is" basis. Krow Workforce makes no warranties, expressed or implied, and hereby disclaims and negates all other warranties including, without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.
|
||||
|
||||
4. LIMITATIONS
|
||||
|
||||
In no event shall Krow Workforce or its suppliers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use the materials on Krow Workforce's App, even if Krow Workforce or a Krow Workforce authorized representative has been notified orally or in writing of the possibility of such damage.
|
||||
|
||||
5. ACCURACY OF MATERIALS
|
||||
|
||||
The materials appearing on Krow Workforce's App could include technical, typographical, or photographic errors. Krow Workforce does not warrant that any of the materials on its App are accurate, complete, or current. Krow Workforce may make changes to the materials contained on its App at any time without notice.
|
||||
|
||||
6. MATERIALS DISCLAIMER
|
||||
|
||||
Krow Workforce has not reviewed all of the sites linked to its App and is not responsible for the contents of any such linked site. The inclusion of any link does not imply endorsement by Krow Workforce of the site. Use of any such linked website is at the user's own risk.
|
||||
|
||||
7. MODIFICATIONS
|
||||
|
||||
Krow Workforce may revise these terms of service for its App at any time without notice. By using this App, you are agreeing to be bound by the then current version of these terms of service.
|
||||
|
||||
8. GOVERNING LAW
|
||||
|
||||
These terms and conditions are governed by and construed in accordance with the laws of the jurisdiction in which Krow Workforce is located, and you irrevocably submit to the exclusive jurisdiction of the courts in that location.
|
||||
|
||||
9. LIMITATION OF LIABILITY
|
||||
|
||||
In no case shall Krow Workforce, its staff, or other contributors be liable for any indirect, incidental, consequential, special, or punitive damages arising out of or relating to the use of the App.
|
||||
|
||||
10. USER CONTENT
|
||||
|
||||
You grant Krow Workforce a non-exclusive, royalty-free, perpetual, and irrevocable right to use any content you provide to us, including but not limited to text, images, and information, in any media or format and for any purpose consistent with our business.
|
||||
|
||||
11. INDEMNIFICATION
|
||||
|
||||
You agree to indemnify and hold harmless Krow Workforce and its staff from any and all claims, damages, losses, costs, and expenses, including attorney's fees, arising out of or resulting from your use of the App or violation of these terms.
|
||||
|
||||
12. TERMINATION
|
||||
|
||||
Krow Workforce reserves the right to terminate your account and access to the App at any time, in its sole discretion, for any reason or no reason, with or without notice.
|
||||
|
||||
13. CONTACT INFORMATION
|
||||
|
||||
If you have any questions about these Terms of Service, please contact us at support@krow.com.
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
|
||||
import '../../domain/repositories/privacy_settings_repository_interface.dart';
|
||||
|
||||
/// Data layer implementation of privacy settings repository
|
||||
///
|
||||
/// Handles all backend communication for privacy settings via Data Connect,
|
||||
/// and loads legal documents from app assets
|
||||
class PrivacySettingsRepositoryImpl
|
||||
implements PrivacySettingsRepositoryInterface {
|
||||
PrivacySettingsRepositoryImpl(this._service);
|
||||
|
||||
final DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<bool> getProfileVisibility() async {
|
||||
return _service.run<bool>(() async {
|
||||
// Get current user ID
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
// Call Data Connect query: getStaffProfileVisibility
|
||||
final fdc.QueryResult<
|
||||
GetStaffProfileVisibilityData,
|
||||
GetStaffProfileVisibilityVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffProfileVisibility(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
// Return the profile visibility status from the first result
|
||||
if (response.data.staff != null) {
|
||||
return response.data.staff?.isProfileVisible ?? true;
|
||||
}
|
||||
|
||||
// Default to visible if no staff record found
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> updateProfileVisibility(bool isVisible) async {
|
||||
return _service.run<bool>(() async {
|
||||
// Get staff ID for the current user
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
// Call Data Connect mutation: UpdateStaffProfileVisibility
|
||||
await _service.connector
|
||||
.updateStaffProfileVisibility(
|
||||
id: staffId,
|
||||
isProfileVisible: isVisible,
|
||||
)
|
||||
.execute();
|
||||
|
||||
// Return the requested visibility state
|
||||
return isVisible;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getTermsOfService() async {
|
||||
return _service.run<String>(() async {
|
||||
try {
|
||||
// Load from package asset path
|
||||
return await rootBundle.loadString(
|
||||
'packages/staff_privacy_security/lib/src/assets/legal/terms_of_service.txt',
|
||||
);
|
||||
} catch (e) {
|
||||
// Final fallback if asset not found
|
||||
print('Error loading terms of service: $e');
|
||||
return 'Terms of Service - Content unavailable. Please contact support@krow.com';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getPrivacyPolicy() async {
|
||||
return _service.run<String>(() async {
|
||||
try {
|
||||
// Load from package asset path
|
||||
return await rootBundle.loadString(
|
||||
'packages/staff_privacy_security/lib/src/assets/legal/privacy_policy.txt',
|
||||
);
|
||||
} catch (e) {
|
||||
// Final fallback if asset not found
|
||||
print('Error loading privacy policy: $e');
|
||||
return 'Privacy Policy - Content unavailable. Please contact privacy@krow.com';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Privacy settings entity representing user privacy preferences
|
||||
class PrivacySettingsEntity extends Equatable {
|
||||
/// Whether location sharing during shifts is enabled
|
||||
final bool locationSharing;
|
||||
|
||||
/// The timestamp when these settings were last updated
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const PrivacySettingsEntity({
|
||||
required this.locationSharing,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
/// Create a copy with optional field overrides
|
||||
PrivacySettingsEntity copyWith({
|
||||
bool? locationSharing,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return PrivacySettingsEntity(
|
||||
locationSharing: locationSharing ?? this.locationSharing,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [locationSharing, updatedAt];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/// Interface for privacy settings repository operations
|
||||
abstract class PrivacySettingsRepositoryInterface {
|
||||
/// Fetch the current staff member's profile visibility setting
|
||||
Future<bool> getProfileVisibility();
|
||||
|
||||
/// Update profile visibility preference
|
||||
///
|
||||
/// Returns the updated profile visibility status
|
||||
Future<bool> updateProfileVisibility(bool isVisible);
|
||||
|
||||
/// Fetch terms of service content
|
||||
Future<String> getTermsOfService();
|
||||
|
||||
/// Fetch privacy policy content
|
||||
Future<String> getPrivacyPolicy();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../repositories/privacy_settings_repository_interface.dart';
|
||||
|
||||
/// Use case to retrieve privacy policy
|
||||
class GetPrivacyPolicyUseCase {
|
||||
final PrivacySettingsRepositoryInterface _repository;
|
||||
|
||||
GetPrivacyPolicyUseCase(this._repository);
|
||||
|
||||
/// Execute the use case to get privacy policy
|
||||
Future<String> call() async {
|
||||
try {
|
||||
return await _repository.getPrivacyPolicy();
|
||||
} catch (e) {
|
||||
return 'Privacy Policy is currently unavailable.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../repositories/privacy_settings_repository_interface.dart';
|
||||
|
||||
/// Use case to retrieve the current staff member's profile visibility setting
|
||||
class GetProfileVisibilityUseCase {
|
||||
final PrivacySettingsRepositoryInterface _repository;
|
||||
|
||||
GetProfileVisibilityUseCase(this._repository);
|
||||
|
||||
/// Execute the use case to get profile visibility status
|
||||
/// Returns true if profile is visible, false if hidden
|
||||
Future<bool> call() async {
|
||||
try {
|
||||
return await _repository.getProfileVisibility();
|
||||
} catch (e) {
|
||||
// Return default (visible) on error
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../repositories/privacy_settings_repository_interface.dart';
|
||||
|
||||
/// Use case to retrieve terms of service
|
||||
class GetTermsUseCase {
|
||||
final PrivacySettingsRepositoryInterface _repository;
|
||||
|
||||
GetTermsUseCase(this._repository);
|
||||
|
||||
/// Execute the use case to get terms of service
|
||||
Future<String> call() async {
|
||||
try {
|
||||
return await _repository.getTermsOfService();
|
||||
} catch (e) {
|
||||
return 'Terms of Service is currently unavailable.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../repositories/privacy_settings_repository_interface.dart';
|
||||
|
||||
/// Parameters for updating profile visibility
|
||||
class UpdateProfileVisibilityParams extends Equatable {
|
||||
/// Whether to show (true) or hide (false) the profile
|
||||
final bool isVisible;
|
||||
|
||||
const UpdateProfileVisibilityParams({required this.isVisible});
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[isVisible];
|
||||
}
|
||||
|
||||
/// Use case to update profile visibility setting
|
||||
class UpdateProfileVisibilityUseCase {
|
||||
final PrivacySettingsRepositoryInterface _repository;
|
||||
|
||||
UpdateProfileVisibilityUseCase(this._repository);
|
||||
|
||||
/// Execute the use case to update profile visibility
|
||||
/// Returns the updated visibility status
|
||||
Future<bool> call(UpdateProfileVisibilityParams params) async {
|
||||
try {
|
||||
return await _repository.updateProfileVisibility(params.isVisible);
|
||||
} catch (e) {
|
||||
// Return the requested state on error (optimistic)
|
||||
return params.isVisible;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../domain/usecases/get_privacy_policy_usecase.dart';
|
||||
|
||||
/// State for Privacy Policy cubit
|
||||
class PrivacyPolicyState {
|
||||
final String? content;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const PrivacyPolicyState({
|
||||
this.content,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
PrivacyPolicyState copyWith({
|
||||
String? content,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return PrivacyPolicyState(
|
||||
content: content ?? this.content,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error ?? this.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cubit for managing Privacy Policy content
|
||||
class PrivacyPolicyCubit extends Cubit<PrivacyPolicyState> {
|
||||
final GetPrivacyPolicyUseCase _getPrivacyPolicyUseCase;
|
||||
|
||||
PrivacyPolicyCubit({
|
||||
required GetPrivacyPolicyUseCase getPrivacyPolicyUseCase,
|
||||
}) : _getPrivacyPolicyUseCase = getPrivacyPolicyUseCase,
|
||||
super(const PrivacyPolicyState());
|
||||
|
||||
/// Fetch privacy policy content
|
||||
Future<void> fetchPrivacyPolicy() async {
|
||||
emit(state.copyWith(isLoading: true, error: null));
|
||||
try {
|
||||
final String content = await _getPrivacyPolicyUseCase();
|
||||
emit(state.copyWith(
|
||||
content: content,
|
||||
isLoading: false,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../domain/usecases/get_terms_usecase.dart';
|
||||
|
||||
/// State for Terms of Service cubit
|
||||
class TermsState {
|
||||
final String? content;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const TermsState({
|
||||
this.content,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
TermsState copyWith({
|
||||
String? content,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return TermsState(
|
||||
content: content ?? this.content,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error ?? this.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cubit for managing Terms of Service content
|
||||
class TermsCubit extends Cubit<TermsState> {
|
||||
final GetTermsUseCase _getTermsUseCase;
|
||||
|
||||
TermsCubit({
|
||||
required GetTermsUseCase getTermsUseCase,
|
||||
}) : _getTermsUseCase = getTermsUseCase,
|
||||
super(const TermsState());
|
||||
|
||||
/// Fetch terms of service content
|
||||
Future<void> fetchTerms() async {
|
||||
emit(state.copyWith(isLoading: true, error: null));
|
||||
try {
|
||||
final String content = await _getTermsUseCase();
|
||||
emit(state.copyWith(
|
||||
content: content,
|
||||
isLoading: false,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../domain/usecases/get_profile_visibility_usecase.dart';
|
||||
import '../../domain/usecases/update_profile_visibility_usecase.dart';
|
||||
import '../../domain/usecases/get_terms_usecase.dart';
|
||||
import '../../domain/usecases/get_privacy_policy_usecase.dart';
|
||||
|
||||
part 'privacy_security_event.dart';
|
||||
part 'privacy_security_state.dart';
|
||||
|
||||
/// BLoC managing privacy and security settings state
|
||||
class PrivacySecurityBloc
|
||||
extends Bloc<PrivacySecurityEvent, PrivacySecurityState> {
|
||||
final GetProfileVisibilityUseCase _getProfileVisibilityUseCase;
|
||||
final UpdateProfileVisibilityUseCase _updateProfileVisibilityUseCase;
|
||||
final GetTermsUseCase _getTermsUseCase;
|
||||
final GetPrivacyPolicyUseCase _getPrivacyPolicyUseCase;
|
||||
|
||||
PrivacySecurityBloc({
|
||||
required GetProfileVisibilityUseCase getProfileVisibilityUseCase,
|
||||
required UpdateProfileVisibilityUseCase updateProfileVisibilityUseCase,
|
||||
required GetTermsUseCase getTermsUseCase,
|
||||
required GetPrivacyPolicyUseCase getPrivacyPolicyUseCase,
|
||||
}) : _getProfileVisibilityUseCase = getProfileVisibilityUseCase,
|
||||
_updateProfileVisibilityUseCase = updateProfileVisibilityUseCase,
|
||||
_getTermsUseCase = getTermsUseCase,
|
||||
_getPrivacyPolicyUseCase = getPrivacyPolicyUseCase,
|
||||
super(const PrivacySecurityState()) {
|
||||
on<FetchProfileVisibilityEvent>(_onFetchProfileVisibility);
|
||||
on<UpdateProfileVisibilityEvent>(_onUpdateProfileVisibility);
|
||||
on<FetchTermsEvent>(_onFetchTerms);
|
||||
on<FetchPrivacyPolicyEvent>(_onFetchPrivacyPolicy);
|
||||
on<ClearProfileVisibilityUpdatedEvent>(_onClearProfileVisibilityUpdated);
|
||||
}
|
||||
|
||||
Future<void> _onFetchProfileVisibility(
|
||||
FetchProfileVisibilityEvent event,
|
||||
Emitter<PrivacySecurityState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true, error: null));
|
||||
|
||||
try {
|
||||
final bool isVisible = await _getProfileVisibilityUseCase.call();
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
isProfileVisible: isVisible,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to fetch profile visibility',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onUpdateProfileVisibility(
|
||||
UpdateProfileVisibilityEvent event,
|
||||
Emitter<PrivacySecurityState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isUpdating: true, error: null, profileVisibilityUpdated: false));
|
||||
|
||||
try {
|
||||
final bool isVisible = await _updateProfileVisibilityUseCase.call(
|
||||
UpdateProfileVisibilityParams(isVisible: event.isVisible),
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
isUpdating: false,
|
||||
isProfileVisible: isVisible,
|
||||
profileVisibilityUpdated: true,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isUpdating: false,
|
||||
error: 'Failed to update profile visibility',
|
||||
profileVisibilityUpdated: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onFetchTerms(
|
||||
FetchTermsEvent event,
|
||||
Emitter<PrivacySecurityState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoadingTerms: true, error: null));
|
||||
|
||||
try {
|
||||
final String content = await _getTermsUseCase.call();
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoadingTerms: false,
|
||||
termsContent: content,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoadingTerms: false,
|
||||
error: 'Failed to fetch terms of service',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onFetchPrivacyPolicy(
|
||||
FetchPrivacyPolicyEvent event,
|
||||
Emitter<PrivacySecurityState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoadingPrivacyPolicy: true, error: null));
|
||||
|
||||
try {
|
||||
final String content = await _getPrivacyPolicyUseCase.call();
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoadingPrivacyPolicy: false,
|
||||
privacyPolicyContent: content,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoadingPrivacyPolicy: false,
|
||||
error: 'Failed to fetch privacy policy',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onClearProfileVisibilityUpdated(
|
||||
ClearProfileVisibilityUpdatedEvent event,
|
||||
Emitter<PrivacySecurityState> emit,
|
||||
) {
|
||||
emit(state.copyWith(profileVisibilityUpdated: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
part of 'privacy_security_bloc.dart';
|
||||
|
||||
/// Base class for privacy security BLoC events
|
||||
abstract class PrivacySecurityEvent extends Equatable {
|
||||
const PrivacySecurityEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Event to fetch current profile visibility setting
|
||||
class FetchProfileVisibilityEvent extends PrivacySecurityEvent {
|
||||
const FetchProfileVisibilityEvent();
|
||||
}
|
||||
|
||||
/// Event to update profile visibility
|
||||
class UpdateProfileVisibilityEvent extends PrivacySecurityEvent {
|
||||
/// Whether to show (true) or hide (false) the profile
|
||||
final bool isVisible;
|
||||
|
||||
const UpdateProfileVisibilityEvent({required this.isVisible});
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[isVisible];
|
||||
}
|
||||
|
||||
/// Event to fetch terms of service
|
||||
class FetchTermsEvent extends PrivacySecurityEvent {
|
||||
const FetchTermsEvent();
|
||||
}
|
||||
|
||||
/// Event to fetch privacy policy
|
||||
class FetchPrivacyPolicyEvent extends PrivacySecurityEvent {
|
||||
const FetchPrivacyPolicyEvent();
|
||||
}
|
||||
|
||||
/// Event to clear the profile visibility updated flag after showing snackbar
|
||||
class ClearProfileVisibilityUpdatedEvent extends PrivacySecurityEvent {
|
||||
const ClearProfileVisibilityUpdatedEvent();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
part of 'privacy_security_bloc.dart';
|
||||
|
||||
/// State for privacy security BLoC
|
||||
class PrivacySecurityState extends Equatable {
|
||||
/// Current profile visibility setting (true = visible, false = hidden)
|
||||
final bool isProfileVisible;
|
||||
|
||||
/// Whether profile visibility is currently loading
|
||||
final bool isLoading;
|
||||
|
||||
/// Whether profile visibility is currently being updated
|
||||
final bool isUpdating;
|
||||
|
||||
/// Whether the profile visibility was just successfully updated
|
||||
final bool profileVisibilityUpdated;
|
||||
|
||||
/// Terms of service content
|
||||
final String? termsContent;
|
||||
|
||||
/// Whether terms are currently loading
|
||||
final bool isLoadingTerms;
|
||||
|
||||
/// Privacy policy content
|
||||
final String? privacyPolicyContent;
|
||||
|
||||
/// Whether privacy policy is currently loading
|
||||
final bool isLoadingPrivacyPolicy;
|
||||
|
||||
/// Error message, if any
|
||||
final String? error;
|
||||
|
||||
const PrivacySecurityState({
|
||||
this.isProfileVisible = true,
|
||||
this.isLoading = false,
|
||||
this.isUpdating = false,
|
||||
this.profileVisibilityUpdated = false,
|
||||
this.termsContent,
|
||||
this.isLoadingTerms = false,
|
||||
this.privacyPolicyContent,
|
||||
this.isLoadingPrivacyPolicy = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// Create a copy with optional field overrides
|
||||
PrivacySecurityState copyWith({
|
||||
bool? isProfileVisible,
|
||||
bool? isLoading,
|
||||
bool? isUpdating,
|
||||
bool? profileVisibilityUpdated,
|
||||
String? termsContent,
|
||||
bool? isLoadingTerms,
|
||||
String? privacyPolicyContent,
|
||||
bool? isLoadingPrivacyPolicy,
|
||||
String? error,
|
||||
}) {
|
||||
return PrivacySecurityState(
|
||||
isProfileVisible: isProfileVisible ?? this.isProfileVisible,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isUpdating: isUpdating ?? this.isUpdating,
|
||||
profileVisibilityUpdated: profileVisibilityUpdated ?? this.profileVisibilityUpdated,
|
||||
termsContent: termsContent ?? this.termsContent,
|
||||
isLoadingTerms: isLoadingTerms ?? this.isLoadingTerms,
|
||||
privacyPolicyContent: privacyPolicyContent ?? this.privacyPolicyContent,
|
||||
isLoadingPrivacyPolicy:
|
||||
isLoadingPrivacyPolicy ?? this.isLoadingPrivacyPolicy,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
isProfileVisible,
|
||||
isLoading,
|
||||
isUpdating,
|
||||
profileVisibilityUpdated,
|
||||
termsContent,
|
||||
isLoadingTerms,
|
||||
privacyPolicyContent,
|
||||
isLoadingPrivacyPolicy,
|
||||
error,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
import '../../blocs/legal/privacy_policy_cubit.dart';
|
||||
|
||||
/// Page displaying the Privacy Policy document
|
||||
class PrivacyPolicyPage extends StatelessWidget {
|
||||
const PrivacyPolicyPage({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: t.staff_privacy_security.privacy_policy.title,
|
||||
showBackButton: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Container(color: UiColors.border, height: 1),
|
||||
),
|
||||
),
|
||||
body: BlocProvider<PrivacyPolicyCubit>(
|
||||
create: (BuildContext context) => Modular.get<PrivacyPolicyCubit>()..fetchPrivacyPolicy(),
|
||||
child: BlocBuilder<PrivacyPolicyCubit, PrivacyPolicyState>(
|
||||
builder: (BuildContext context, PrivacyPolicyState state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Text(
|
||||
'Error loading Privacy Policy: ${state.error}',
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Text(
|
||||
state.content ?? 'No content available',
|
||||
style: UiTypography.body2r.copyWith(
|
||||
height: 1.6,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
import '../../blocs/legal/terms_cubit.dart';
|
||||
|
||||
/// Page displaying the Terms of Service document
|
||||
class TermsOfServicePage extends StatelessWidget {
|
||||
const TermsOfServicePage({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: t.staff_privacy_security.terms_of_service.title,
|
||||
showBackButton: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Container(color: UiColors.border, height: 1),
|
||||
),
|
||||
),
|
||||
body: BlocProvider<TermsCubit>(
|
||||
create: (BuildContext context) => Modular.get<TermsCubit>()..fetchTerms(),
|
||||
child: BlocBuilder<TermsCubit, TermsState>(
|
||||
builder: (BuildContext context, TermsState state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Text(
|
||||
'Error loading Terms of Service: ${state.error}',
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Text(
|
||||
state.content ?? 'No content available',
|
||||
style: UiTypography.body2r.copyWith(
|
||||
height: 1.6,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
import '../blocs/privacy_security_bloc.dart';
|
||||
import '../widgets/legal/legal_section_widget.dart';
|
||||
import '../widgets/privacy/privacy_section_widget.dart';
|
||||
|
||||
/// Page displaying privacy & security settings for staff
|
||||
class PrivacySecurityPage extends StatelessWidget {
|
||||
const PrivacySecurityPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: t.staff_privacy_security.title,
|
||||
showBackButton: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Container(color: UiColors.border, height: 1),
|
||||
),
|
||||
),
|
||||
body: BlocProvider<PrivacySecurityBloc>.value(
|
||||
value: Modular.get<PrivacySecurityBloc>()
|
||||
..add(const FetchProfileVisibilityEvent()),
|
||||
child: BlocBuilder<PrivacySecurityBloc, PrivacySecurityState>(
|
||||
builder: (BuildContext context, PrivacySecurityState state) {
|
||||
if (state.isLoading) {
|
||||
return const UiLoadingPage();
|
||||
}
|
||||
|
||||
return const SingleChildScrollView(
|
||||
padding: EdgeInsets.all(UiConstants.space6),
|
||||
child: Column(
|
||||
spacing: UiConstants.space6,
|
||||
children: <Widget>[
|
||||
// Privacy Section
|
||||
PrivacySectionWidget(),
|
||||
|
||||
// Legal Section
|
||||
LegalSectionWidget(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../../blocs/privacy_security_bloc.dart';
|
||||
import '../settings_action_tile_widget.dart';
|
||||
import '../settings_divider_widget.dart';
|
||||
import '../settings_section_header_widget.dart';
|
||||
|
||||
/// Widget displaying legal documents (Terms of Service and Privacy Policy)
|
||||
class LegalSectionWidget extends StatelessWidget {
|
||||
const LegalSectionWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
spacing: UiConstants.space4,
|
||||
|
||||
children: <Widget>[
|
||||
// Legal Section Header
|
||||
SettingsSectionHeader(
|
||||
title: t.staff_privacy_security.legal_section,
|
||||
icon: UiIcons.shield,
|
||||
),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
SettingsActionTile(
|
||||
title: t.staff_privacy_security.terms_of_service.title,
|
||||
onTap: () => _navigateToTerms(context),
|
||||
),
|
||||
const SettingsDivider(),
|
||||
SettingsActionTile(
|
||||
title: t.staff_privacy_security.privacy_policy.title,
|
||||
onTap: () => _navigateToPrivacyPolicy(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate to terms of service page
|
||||
void _navigateToTerms(BuildContext context) {
|
||||
BlocProvider.of<PrivacySecurityBloc>(context).add(const FetchTermsEvent());
|
||||
|
||||
// Navigate using typed navigator
|
||||
Modular.to.toTermsOfService();
|
||||
}
|
||||
|
||||
/// Navigate to privacy policy page
|
||||
void _navigateToPrivacyPolicy(BuildContext context) {
|
||||
BlocProvider.of<PrivacySecurityBloc>(
|
||||
context,
|
||||
).add(const FetchPrivacyPolicyEvent());
|
||||
|
||||
// Navigate using typed navigator
|
||||
Modular.to.toPrivacyPolicy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../blocs/privacy_security_bloc.dart';
|
||||
import '../settings_section_header_widget.dart';
|
||||
import '../settings_switch_tile_widget.dart';
|
||||
|
||||
/// Widget displaying privacy settings including profile visibility preference
|
||||
class PrivacySectionWidget extends StatelessWidget {
|
||||
const PrivacySectionWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<PrivacySecurityBloc, PrivacySecurityState>(
|
||||
listener: (BuildContext context, PrivacySecurityState state) {
|
||||
// Show success message when profile visibility update just completed
|
||||
if (state.profileVisibilityUpdated && state.error == null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: t.staff_privacy_security.success.profile_visibility_updated,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
// Clear the flag after showing the snackbar
|
||||
context.read<PrivacySecurityBloc>().add(
|
||||
const ClearProfileVisibilityUpdatedEvent(),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<PrivacySecurityBloc, PrivacySecurityState>(
|
||||
builder: (BuildContext context, PrivacySecurityState state) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
// Privacy Section Header
|
||||
SettingsSectionHeader(
|
||||
title: t.staff_privacy_security.privacy_section,
|
||||
icon: UiIcons.eye,
|
||||
),
|
||||
const SizedBox(height: 12.0),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(
|
||||
color: UiColors.border,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
SettingsSwitchTile(
|
||||
title: t.staff_privacy_security.profile_visibility.title,
|
||||
subtitle: t.staff_privacy_security.profile_visibility.subtitle,
|
||||
value: state.isProfileVisible,
|
||||
onChanged: (bool value) {
|
||||
BlocProvider.of<PrivacySecurityBloc>(context).add(
|
||||
UpdateProfileVisibilityEvent(isVisible: value),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
|
||||
/// Reusable widget for action tile (tap to navigate)
|
||||
class SettingsActionTile extends StatelessWidget {
|
||||
/// The title of the action
|
||||
final String title;
|
||||
|
||||
/// Optional subtitle describing the action
|
||||
final String? subtitle;
|
||||
|
||||
/// Callback when tile is tapped
|
||||
final VoidCallback onTap;
|
||||
|
||||
const SettingsActionTile({
|
||||
Key? key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...<Widget>[
|
||||
SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: UiTypography.footnote1r.copyWith(
|
||||
color: UiColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
UiIcons.chevronRight,
|
||||
size: 16,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
|
||||
/// Divider widget for separating items within settings sections
|
||||
class SettingsDivider extends StatelessWidget {
|
||||
const SettingsDivider({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Divider(
|
||||
height: 1,
|
||||
color: UiColors.border,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
|
||||
/// Reusable widget for settings section header with icon
|
||||
class SettingsSectionHeader extends StatelessWidget {
|
||||
/// The title of the section
|
||||
final String title;
|
||||
|
||||
/// The icon to display next to the title
|
||||
final IconData icon;
|
||||
|
||||
const SettingsSectionHeader({
|
||||
Key? key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body1r.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
|
||||
/// Reusable widget for toggle tile in privacy settings
|
||||
class SettingsSwitchTile extends StatelessWidget {
|
||||
/// The title of the setting
|
||||
final String title;
|
||||
|
||||
/// The subtitle describing the setting
|
||||
final String subtitle;
|
||||
|
||||
/// Current toggle value
|
||||
final bool value;
|
||||
|
||||
/// Callback when toggle is changed
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const SettingsSwitchTile({
|
||||
Key? key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(title, style: UiTypography.body2r),
|
||||
Text(subtitle, style: UiTypography.footnote1r.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
|
||||
import 'data/repositories_impl/privacy_settings_repository_impl.dart';
|
||||
import 'domain/repositories/privacy_settings_repository_interface.dart';
|
||||
import 'domain/usecases/get_privacy_policy_usecase.dart';
|
||||
import 'domain/usecases/get_profile_visibility_usecase.dart';
|
||||
import 'domain/usecases/get_terms_usecase.dart';
|
||||
import 'domain/usecases/update_profile_visibility_usecase.dart';
|
||||
import 'presentation/blocs/legal/privacy_policy_cubit.dart';
|
||||
import 'presentation/blocs/legal/terms_cubit.dart';
|
||||
import 'presentation/blocs/privacy_security_bloc.dart';
|
||||
import 'presentation/pages/legal/privacy_policy_page.dart';
|
||||
import 'presentation/pages/legal/terms_of_service_page.dart';
|
||||
import 'presentation/pages/privacy_security_page.dart';
|
||||
|
||||
/// Module for privacy security feature
|
||||
///
|
||||
/// Provides:
|
||||
/// - Dependency injection for repositories, use cases, and BLoCs
|
||||
/// - Route definitions delegated to core routing
|
||||
class PrivacySecurityModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.addSingleton<PrivacySettingsRepositoryInterface>(
|
||||
() => PrivacySettingsRepositoryImpl(
|
||||
Modular.get<DataConnectService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.addSingleton(
|
||||
() => GetProfileVisibilityUseCase(
|
||||
i<PrivacySettingsRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
i.addSingleton(
|
||||
() => UpdateProfileVisibilityUseCase(
|
||||
i<PrivacySettingsRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
i.addSingleton(
|
||||
() => GetTermsUseCase(
|
||||
i<PrivacySettingsRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
i.addSingleton(
|
||||
() => GetPrivacyPolicyUseCase(
|
||||
i<PrivacySettingsRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
|
||||
// BLoC
|
||||
i.add(
|
||||
() => PrivacySecurityBloc(
|
||||
getProfileVisibilityUseCase: i(),
|
||||
updateProfileVisibilityUseCase: i(),
|
||||
getTermsUseCase: i(),
|
||||
getPrivacyPolicyUseCase: i(),
|
||||
),
|
||||
);
|
||||
|
||||
// Legal Cubits
|
||||
i.add(
|
||||
() => TermsCubit(
|
||||
getTermsUseCase: i<GetTermsUseCase>(),
|
||||
),
|
||||
);
|
||||
|
||||
i.add(
|
||||
() => PrivacyPolicyCubit(
|
||||
getPrivacyPolicyUseCase: i<GetPrivacyPolicyUseCase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
// Main privacy security page
|
||||
r.child(
|
||||
StaffPaths.childRoute(
|
||||
StaffPaths.privacySecurity,
|
||||
StaffPaths.privacySecurity,
|
||||
),
|
||||
child: (BuildContext context) => const PrivacySecurityPage(),
|
||||
);
|
||||
|
||||
// Terms of Service page
|
||||
r.child(
|
||||
StaffPaths.childRoute(
|
||||
StaffPaths.privacySecurity,
|
||||
StaffPaths.termsOfService,
|
||||
),
|
||||
child: (BuildContext context) => const TermsOfServicePage(),
|
||||
);
|
||||
|
||||
// Privacy Policy page
|
||||
r.child(
|
||||
StaffPaths.childRoute(
|
||||
StaffPaths.privacySecurity,
|
||||
StaffPaths.privacyPolicy,
|
||||
),
|
||||
child: (BuildContext context) => const PrivacyPolicyPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export 'src/domain/entities/privacy_settings_entity.dart';
|
||||
export 'src/domain/repositories/privacy_settings_repository_interface.dart';
|
||||
export 'src/domain/usecases/get_terms_usecase.dart';
|
||||
export 'src/domain/usecases/get_privacy_policy_usecase.dart';
|
||||
export 'src/data/repositories_impl/privacy_settings_repository_impl.dart';
|
||||
export 'src/presentation/blocs/privacy_security_bloc.dart';
|
||||
export 'src/presentation/pages/privacy_security_page.dart';
|
||||
export 'src/presentation/widgets/settings_switch_tile_widget.dart';
|
||||
export 'src/presentation/widgets/settings_action_tile_widget.dart';
|
||||
export 'src/presentation/widgets/settings_section_header_widget.dart';
|
||||
export 'src/presentation/widgets/settings_divider_widget.dart';
|
||||
export 'src/staff_privacy_security_module.dart';
|
||||
@@ -0,0 +1,42 @@
|
||||
name: staff_privacy_security
|
||||
description: Privacy & Security settings feature for staff application.
|
||||
version: 0.0.1
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.10.0 <4.0.0'
|
||||
flutter: ">=3.0.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
firebase_data_connect: ^0.2.2+1
|
||||
url_launcher: ^6.2.0
|
||||
|
||||
# Architecture Packages
|
||||
krow_domain:
|
||||
path: ../../../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
krow_core:
|
||||
path: ../../../../../core
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
core_localization:
|
||||
path: ../../../../../core_localization
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
bloc_test: ^9.1.0
|
||||
mocktail: ^1.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- lib/src/assets/legal/
|
||||
@@ -15,6 +15,8 @@ class ShiftsRepositoryImpl
|
||||
// Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation)
|
||||
final Map<String, String> _appToRoleIdMap = {};
|
||||
|
||||
// This need to be an APPLICATION
|
||||
// THERE SHOULD BE APPLICATIONSTATUS and SHIFTSTATUS enums in the domain layer to avoid this string mapping and potential bugs.
|
||||
@override
|
||||
Future<List<Shift>> getMyShifts({
|
||||
required DateTime start,
|
||||
@@ -187,8 +189,6 @@ class ShiftsRepositoryImpl
|
||||
.listShiftRolesByVendorId(vendorId: vendorId)
|
||||
.execute());
|
||||
|
||||
|
||||
|
||||
final allShiftRoles = result.data.shiftRoles;
|
||||
|
||||
// Fetch my applications to filter out already booked shifts
|
||||
|
||||
@@ -175,30 +175,30 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
child: state is ShiftsLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: state is ShiftsError
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
translateErrorKey(state.message),
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
translateErrorKey(state.message),
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _buildTabContent(
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: _buildTabContent(
|
||||
myShifts,
|
||||
pendingAssignments,
|
||||
cancelledShifts,
|
||||
availableJobs,
|
||||
historyShifts,
|
||||
availableLoading,
|
||||
historyLoading,
|
||||
),
|
||||
pendingAssignments,
|
||||
cancelledShifts,
|
||||
availableJobs,
|
||||
historyShifts,
|
||||
availableLoading,
|
||||
historyLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -254,14 +254,14 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
onTap: !enabled
|
||||
? null
|
||||
: () {
|
||||
setState(() => _activeTab = id);
|
||||
if (id == 'history') {
|
||||
_bloc.add(LoadHistoryShiftsEvent());
|
||||
}
|
||||
if (id == 'find') {
|
||||
_bloc.add(LoadAvailableShiftsEvent());
|
||||
}
|
||||
},
|
||||
setState(() => _activeTab = id);
|
||||
if (id == 'history') {
|
||||
_bloc.add(LoadHistoryShiftsEvent());
|
||||
}
|
||||
if (id == 'find') {
|
||||
_bloc.add(LoadAvailableShiftsEvent());
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space2,
|
||||
@@ -290,9 +290,17 @@ class _ShiftsPageState extends State<ShiftsPage> {
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: (isActive ? UiTypography.body3m.copyWith(color: UiColors.primary) : UiTypography.body3m.white).copyWith(
|
||||
color: !enabled ? UiColors.white.withValues(alpha: 0.5) : null,
|
||||
),
|
||||
style:
|
||||
(isActive
|
||||
? UiTypography.body3m.copyWith(
|
||||
color: UiColors.primary,
|
||||
)
|
||||
: UiTypography.body3m.white)
|
||||
.copyWith(
|
||||
color: !enabled
|
||||
? UiColors.white.withValues(alpha: 0.5)
|
||||
: null,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -32,6 +32,8 @@ dependencies:
|
||||
url_launcher: ^6.3.1
|
||||
firebase_auth: ^6.1.4
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
meta: ^1.17.0
|
||||
bloc: ^8.1.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -12,11 +12,13 @@ import 'package:staff_home/staff_home.dart';
|
||||
import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart';
|
||||
import 'package:staff_main/src/presentation/pages/staff_main_page.dart';
|
||||
import 'package:staff_payments/staff_payements.dart';
|
||||
import 'package:staff_privacy_security/staff_privacy_security.dart';
|
||||
import 'package:staff_profile/staff_profile.dart';
|
||||
import 'package:staff_profile_experience/staff_profile_experience.dart';
|
||||
import 'package:staff_profile_info/staff_profile_info.dart';
|
||||
import 'package:staff_shifts/staff_shifts.dart';
|
||||
import 'package:staff_tax_forms/staff_tax_forms.dart';
|
||||
import 'package:staff_faqs/staff_faqs.dart';
|
||||
import 'package:staff_time_card/staff_time_card.dart';
|
||||
|
||||
class StaffMainModule extends Module {
|
||||
@@ -93,9 +95,17 @@ class StaffMainModule extends Module {
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.availability),
|
||||
module: StaffAvailabilityModule(),
|
||||
);
|
||||
r.module(
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.privacySecurity),
|
||||
module: PrivacySecurityModule(),
|
||||
);
|
||||
r.module(
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.shiftDetailsRoute),
|
||||
module: ShiftDetailsModule(),
|
||||
);
|
||||
r.module(
|
||||
StaffPaths.childRoute(StaffPaths.main, StaffPaths.faqs),
|
||||
module: FaqsModule(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ dependencies:
|
||||
path: ../../../design_system
|
||||
core_localization:
|
||||
path: ../../../core_localization
|
||||
krow_core:
|
||||
path: ../../../krow_core
|
||||
|
||||
# Features
|
||||
staff_home:
|
||||
@@ -52,6 +54,10 @@ dependencies:
|
||||
path: ../availability
|
||||
staff_clock_in:
|
||||
path: ../clock_in
|
||||
staff_privacy_security:
|
||||
path: ../profile_sections/support/privacy_security
|
||||
staff_faqs:
|
||||
path: ../profile_sections/support/faqs
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1290,6 +1290,20 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
staff_faqs:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: "packages/features/staff/profile_sections/support/faqs"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
staff_privacy_security:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: "packages/features/staff/profile_sections/support/privacy_security"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user