Merge dev
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'route_paths.dart';
|
||||
|
||||
@@ -145,6 +146,22 @@ extension ClientNavigator on IModularNavigator {
|
||||
await pushNamed(ClientPaths.hubs);
|
||||
}
|
||||
|
||||
/// Navigates to the details of a specific hub.
|
||||
Future<bool?> toHubDetails(Hub hub) {
|
||||
return pushNamed<bool?>(
|
||||
ClientPaths.hubDetails,
|
||||
arguments: <String, dynamic>{'hub': hub},
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigates to the page to add a new hub or edit an existing one.
|
||||
Future<bool?> toEditHub({Hub? hub}) async {
|
||||
return pushNamed<bool?>(
|
||||
ClientPaths.editHub,
|
||||
arguments: <String, dynamic>{'hub': hub},
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ORDER CREATION
|
||||
// ==========================================================================
|
||||
|
||||
@@ -16,14 +16,14 @@ class ClientPaths {
|
||||
/// Generate child route based on the given route and parent route
|
||||
///
|
||||
/// This is useful for creating nested routes within modules.
|
||||
static String childRoute(String parent, String child) {
|
||||
static String childRoute(String parent, String child) {
|
||||
final String childPath = child.replaceFirst(parent, '');
|
||||
|
||||
|
||||
// check if the child path is empty
|
||||
if (childPath.isEmpty) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ensure the child path starts with a '/'
|
||||
if (!childPath.startsWith('/')) {
|
||||
return '/$childPath';
|
||||
@@ -82,10 +82,12 @@ class ClientPaths {
|
||||
static const String billing = '/client-main/billing';
|
||||
|
||||
/// Completion review page - review shift completion records.
|
||||
static const String completionReview = '/client-main/billing/completion-review';
|
||||
static const String completionReview =
|
||||
'/client-main/billing/completion-review';
|
||||
|
||||
/// Full list of invoices awaiting approval.
|
||||
static const String awaitingApproval = '/client-main/billing/awaiting-approval';
|
||||
static const String awaitingApproval =
|
||||
'/client-main/billing/awaiting-approval';
|
||||
|
||||
/// Invoice ready page - view status of approved invoices.
|
||||
static const String invoiceReady = '/client-main/billing/invoice-ready';
|
||||
@@ -118,6 +120,12 @@ class ClientPaths {
|
||||
/// View and manage physical locations/hubs where staff are deployed.
|
||||
static const String hubs = '/client-hubs';
|
||||
|
||||
/// Specific hub details.
|
||||
static const String hubDetails = '/client-hubs/details';
|
||||
|
||||
/// Page for adding or editing a hub.
|
||||
static const String editHub = '/client-hubs/edit';
|
||||
|
||||
// ==========================================================================
|
||||
// ORDER CREATION & MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
@@ -1045,7 +1045,7 @@
|
||||
}
|
||||
},
|
||||
"staff_profile_attire": {
|
||||
"title": "Attire",
|
||||
"title": "Verify Attire",
|
||||
"info_card": {
|
||||
"title": "Your Wardrobe",
|
||||
"description": "Select the attire items you own. This helps us match you with shifts that fit your wardrobe."
|
||||
|
||||
@@ -1045,7 +1045,7 @@
|
||||
}
|
||||
},
|
||||
"staff_profile_attire": {
|
||||
"title": "Vestimenta",
|
||||
"title": "Verificar Vestimenta",
|
||||
"info_card": {
|
||||
"title": "Tu Vestuario",
|
||||
"description": "Selecciona los art\u00edculos de vestimenta que posees. Esto nos ayuda a asignarte turnos que se ajusten a tu vestuario."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart'
|
||||
hide AttireVerificationStatus;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Implementation of [StaffConnectorRepository].
|
||||
@@ -11,9 +12,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
/// Creates a new [StaffConnectorRepositoryImpl].
|
||||
///
|
||||
/// Requires a [DataConnectService] instance for backend communication.
|
||||
StaffConnectorRepositoryImpl({
|
||||
DataConnectService? service,
|
||||
}) : _service = service ?? DataConnectService.instance;
|
||||
StaffConnectorRepositoryImpl({DataConnectService? service})
|
||||
: _service = service ?? DataConnectService.instance;
|
||||
|
||||
final DataConnectService _service;
|
||||
|
||||
@@ -22,15 +22,17 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<GetStaffProfileCompletionData,
|
||||
GetStaffProfileCompletionVariables> response =
|
||||
await _service.connector
|
||||
.getStaffProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
final QueryResult<
|
||||
GetStaffProfileCompletionData,
|
||||
GetStaffProfileCompletionVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
|
||||
final GetStaffProfileCompletionStaff? staff = response.data.staff;
|
||||
final List<GetStaffProfileCompletionEmergencyContacts>
|
||||
emergencyContacts = response.data.emergencyContacts;
|
||||
final List<GetStaffProfileCompletionEmergencyContacts> emergencyContacts =
|
||||
response.data.emergencyContacts;
|
||||
final List<GetStaffProfileCompletionTaxForms> taxForms =
|
||||
response.data.taxForms;
|
||||
|
||||
@@ -43,11 +45,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<GetStaffPersonalInfoCompletionData,
|
||||
GetStaffPersonalInfoCompletionVariables> response =
|
||||
await _service.connector
|
||||
.getStaffPersonalInfoCompletion(id: staffId)
|
||||
.execute();
|
||||
final QueryResult<
|
||||
GetStaffPersonalInfoCompletionData,
|
||||
GetStaffPersonalInfoCompletionVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffPersonalInfoCompletion(id: staffId)
|
||||
.execute();
|
||||
|
||||
final GetStaffPersonalInfoCompletionStaff? staff = response.data.staff;
|
||||
|
||||
@@ -60,11 +64,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<GetStaffEmergencyProfileCompletionData,
|
||||
GetStaffEmergencyProfileCompletionVariables> response =
|
||||
await _service.connector
|
||||
.getStaffEmergencyProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
final QueryResult<
|
||||
GetStaffEmergencyProfileCompletionData,
|
||||
GetStaffEmergencyProfileCompletionVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffEmergencyProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
|
||||
return response.data.emergencyContacts.isNotEmpty;
|
||||
});
|
||||
@@ -75,11 +81,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<GetStaffExperienceProfileCompletionData,
|
||||
GetStaffExperienceProfileCompletionVariables> response =
|
||||
await _service.connector
|
||||
.getStaffExperienceProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
final QueryResult<
|
||||
GetStaffExperienceProfileCompletionData,
|
||||
GetStaffExperienceProfileCompletionVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffExperienceProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
|
||||
final GetStaffExperienceProfileCompletionStaff? staff =
|
||||
response.data.staff;
|
||||
@@ -93,11 +101,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<GetStaffTaxFormsProfileCompletionData,
|
||||
GetStaffTaxFormsProfileCompletionVariables> response =
|
||||
await _service.connector
|
||||
.getStaffTaxFormsProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
final QueryResult<
|
||||
GetStaffTaxFormsProfileCompletionData,
|
||||
GetStaffTaxFormsProfileCompletionVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffTaxFormsProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
|
||||
return response.data.taxForms.isNotEmpty;
|
||||
});
|
||||
@@ -135,9 +145,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
final bool hasExperience =
|
||||
(skills is List && skills.isNotEmpty) ||
|
||||
(industries is List && industries.isNotEmpty);
|
||||
return emergencyContacts.isNotEmpty &&
|
||||
taxForms.isNotEmpty &&
|
||||
hasExperience;
|
||||
return emergencyContacts.isNotEmpty && taxForms.isNotEmpty && hasExperience;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -146,14 +154,10 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<GetStaffByIdData, GetStaffByIdVariables> response =
|
||||
await _service.connector
|
||||
.getStaffById(id: staffId)
|
||||
.execute();
|
||||
await _service.connector.getStaffById(id: staffId).execute();
|
||||
|
||||
if (response.data.staff == null) {
|
||||
throw const ServerException(
|
||||
technicalMessage: 'Staff not found',
|
||||
);
|
||||
throw const ServerException(technicalMessage: 'Staff not found');
|
||||
}
|
||||
|
||||
final GetStaffByIdStaff rawStaff = response.data.staff!;
|
||||
@@ -183,23 +187,87 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<ListBenefitsDataByStaffIdData,
|
||||
ListBenefitsDataByStaffIdVariables> response =
|
||||
await _service.connector
|
||||
.listBenefitsDataByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
final QueryResult<
|
||||
ListBenefitsDataByStaffIdData,
|
||||
ListBenefitsDataByStaffIdVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.listBenefitsDataByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
return response.data.benefitsDatas.map((data) {
|
||||
final plan = data.vendorBenefitPlan;
|
||||
return Benefit(
|
||||
title: plan.title,
|
||||
entitlementHours: plan.total?.toDouble() ?? 0.0,
|
||||
usedHours: (plan.total ?? 0) - data.current.toDouble(),
|
||||
usedHours: data.current.toDouble(),
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AttireItem>> getAttireOptions() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
// Fetch all options
|
||||
final QueryResult<ListAttireOptionsData, void> optionsResponse =
|
||||
await _service.connector.listAttireOptions().execute();
|
||||
|
||||
// Fetch user's attire status
|
||||
final QueryResult<GetStaffAttireData, GetStaffAttireVariables>
|
||||
attiresResponse = await _service.connector
|
||||
.getStaffAttire(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final Map<String, GetStaffAttireStaffAttires> attireMap = {
|
||||
for (final item in attiresResponse.data.staffAttires)
|
||||
item.attireOptionId: item,
|
||||
};
|
||||
|
||||
return optionsResponse.data.attireOptions.map((e) {
|
||||
final GetStaffAttireStaffAttires? userAttire = attireMap[e.id];
|
||||
return AttireItem(
|
||||
id: e.itemId,
|
||||
label: e.label,
|
||||
description: e.description,
|
||||
imageUrl: e.imageUrl,
|
||||
isMandatory: e.isMandatory ?? false,
|
||||
verificationStatus: _mapAttireStatus(
|
||||
userAttire?.verificationStatus?.stringValue,
|
||||
),
|
||||
photoUrl: userAttire?.verificationPhotoUrl,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
AttireVerificationStatus? _mapAttireStatus(String? status) {
|
||||
if (status == null) return null;
|
||||
return AttireVerificationStatus.values.firstWhere(
|
||||
(e) => e.name.toUpperCase() == status.toUpperCase(),
|
||||
orElse: () => AttireVerificationStatus.pending,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertStaffAttire({
|
||||
required String attireOptionId,
|
||||
required String photoUrl,
|
||||
String? verificationId,
|
||||
}) async {
|
||||
await _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
await _service.connector
|
||||
.upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId)
|
||||
.verificationPhotoUrl(photoUrl)
|
||||
// .verificationId(verificationId) // Uncomment after SDK regeneration
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
@@ -210,4 +278,3 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,18 @@ abstract interface class StaffConnectorRepository {
|
||||
/// Returns a list of [Benefit] entities.
|
||||
Future<List<Benefit>> getBenefits();
|
||||
|
||||
/// Fetches the attire options for the current authenticated user.
|
||||
///
|
||||
/// Returns a list of [AttireItem] entities.
|
||||
Future<List<AttireItem>> getAttireOptions();
|
||||
|
||||
/// Upserts staff attire photo information.
|
||||
Future<void> upsertStaffAttire({
|
||||
required String attireOptionId,
|
||||
required String photoUrl,
|
||||
String? verificationId,
|
||||
});
|
||||
|
||||
/// Signs out the current user.
|
||||
///
|
||||
/// Clears the user's session and authentication state.
|
||||
|
||||
@@ -276,4 +276,7 @@ class UiIcons {
|
||||
|
||||
/// Help circle icon for FAQs
|
||||
static const IconData helpCircle = _IconLib.helpCircle;
|
||||
|
||||
/// Gallery icon for gallery
|
||||
static const IconData gallery = _IconLib.galleryVertical;
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ class UiTypography {
|
||||
/// Body 4 Regular - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826)
|
||||
static final TextStyle body4r = _primaryBase.copyWith(
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 12,
|
||||
fontSize: 10,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.05,
|
||||
color: UiColors.textPrimary,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:design_system/src/ui_typography.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../ui_icons.dart';
|
||||
import 'ui_icon_button.dart';
|
||||
|
||||
/// A custom AppBar for the Krow UI design system.
|
||||
///
|
||||
/// This widget provides a consistent look and feel for top app bars across the application.
|
||||
@@ -12,6 +8,7 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const UiAppBar({
|
||||
super.key,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.titleWidget,
|
||||
this.leading,
|
||||
this.actions,
|
||||
@@ -25,6 +22,9 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
/// The title text to display in the app bar.
|
||||
final String? title;
|
||||
|
||||
/// The subtitle text to display in the app bar.
|
||||
final String? subtitle;
|
||||
|
||||
/// A widget to display instead of the title text.
|
||||
final Widget? titleWidget;
|
||||
|
||||
@@ -57,7 +57,19 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
return AppBar(
|
||||
title:
|
||||
titleWidget ??
|
||||
(title != null ? Text(title!, style: UiTypography.headline4b) : null),
|
||||
(title != null
|
||||
? Column(
|
||||
crossAxisAlignment: centerTitle
|
||||
? CrossAxisAlignment.center
|
||||
: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(title!, style: UiTypography.headline4b),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!, style: UiTypography.body3r.textSecondary),
|
||||
],
|
||||
)
|
||||
: null),
|
||||
leading:
|
||||
leading ??
|
||||
(showBackButton
|
||||
|
||||
@@ -5,6 +5,9 @@ import '../ui_typography.dart';
|
||||
|
||||
/// Sizes for the [UiChip] widget.
|
||||
enum UiChipSize {
|
||||
// X-Small size (e.g. for tags in tight spaces).
|
||||
xSmall,
|
||||
|
||||
/// Small size (e.g. for tags in tight spaces).
|
||||
small,
|
||||
|
||||
@@ -25,6 +28,9 @@ enum UiChipVariant {
|
||||
|
||||
/// Accent style with highlight background.
|
||||
accent,
|
||||
|
||||
/// Desructive style with red background.
|
||||
destructive,
|
||||
}
|
||||
|
||||
/// A custom chip widget with supports for different sizes, themes, and icons.
|
||||
@@ -119,6 +125,8 @@ class UiChip extends StatelessWidget {
|
||||
return UiColors.tagInProgress;
|
||||
case UiChipVariant.accent:
|
||||
return UiColors.accent;
|
||||
case UiChipVariant.destructive:
|
||||
return UiColors.iconError.withValues(alpha: 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,11 +142,15 @@ class UiChip extends StatelessWidget {
|
||||
return UiColors.primary;
|
||||
case UiChipVariant.accent:
|
||||
return UiColors.accentForeground;
|
||||
case UiChipVariant.destructive:
|
||||
return UiColors.iconError;
|
||||
}
|
||||
}
|
||||
|
||||
TextStyle _getTextStyle() {
|
||||
switch (size) {
|
||||
case UiChipSize.xSmall:
|
||||
return UiTypography.body4r;
|
||||
case UiChipSize.small:
|
||||
return UiTypography.body3r;
|
||||
case UiChipSize.medium:
|
||||
@@ -150,6 +162,8 @@ class UiChip extends StatelessWidget {
|
||||
|
||||
EdgeInsets _getPadding() {
|
||||
switch (size) {
|
||||
case UiChipSize.xSmall:
|
||||
return const EdgeInsets.symmetric(horizontal: 6, vertical: 4);
|
||||
case UiChipSize.small:
|
||||
return const EdgeInsets.symmetric(horizontal: 10, vertical: 6);
|
||||
case UiChipSize.medium:
|
||||
@@ -161,6 +175,8 @@ class UiChip extends StatelessWidget {
|
||||
|
||||
double _getIconSize() {
|
||||
switch (size) {
|
||||
case UiChipSize.xSmall:
|
||||
return 10;
|
||||
case UiChipSize.small:
|
||||
return 12;
|
||||
case UiChipSize.medium:
|
||||
@@ -172,6 +188,8 @@ class UiChip extends StatelessWidget {
|
||||
|
||||
double _getGap() {
|
||||
switch (size) {
|
||||
case UiChipSize.xSmall:
|
||||
return UiConstants.space1;
|
||||
case UiChipSize.small:
|
||||
return UiConstants.space1;
|
||||
case UiChipSize.medium:
|
||||
|
||||
@@ -68,6 +68,7 @@ export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
|
||||
// Profile
|
||||
export 'src/entities/profile/staff_document.dart';
|
||||
export 'src/entities/profile/attire_item.dart';
|
||||
export 'src/entities/profile/attire_verification_status.dart';
|
||||
export 'src/entities/profile/relationship_type.dart';
|
||||
export 'src/entities/profile/industry.dart';
|
||||
export 'src/entities/profile/tax_form.dart';
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'attire_verification_status.dart';
|
||||
|
||||
/// Represents an attire item that a staff member might need or possess.
|
||||
///
|
||||
/// Attire items are specific clothing or equipment required for jobs.
|
||||
class AttireItem extends Equatable {
|
||||
|
||||
/// Creates an [AttireItem].
|
||||
const AttireItem({
|
||||
required this.id,
|
||||
required this.label,
|
||||
this.iconName,
|
||||
this.description,
|
||||
this.imageUrl,
|
||||
this.isMandatory = false,
|
||||
this.verificationStatus,
|
||||
this.photoUrl,
|
||||
this.verificationId,
|
||||
});
|
||||
|
||||
/// Unique identifier of the attire item.
|
||||
final String id;
|
||||
|
||||
/// Display name of the item.
|
||||
final String label;
|
||||
|
||||
/// Name of the icon to display (mapped in UI).
|
||||
final String? iconName;
|
||||
/// Optional description for the attire item.
|
||||
final String? description;
|
||||
|
||||
/// URL of the reference image.
|
||||
final String? imageUrl;
|
||||
@@ -28,6 +33,24 @@ class AttireItem extends Equatable {
|
||||
/// Whether this item is mandatory for onboarding.
|
||||
final bool isMandatory;
|
||||
|
||||
/// The current verification status of the uploaded photo.
|
||||
final AttireVerificationStatus? verificationStatus;
|
||||
|
||||
/// The URL of the photo uploaded by the staff member.
|
||||
final String? photoUrl;
|
||||
|
||||
/// The ID of the verification record.
|
||||
final String? verificationId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, label, iconName, imageUrl, isMandatory];
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
imageUrl,
|
||||
isMandatory,
|
||||
verificationStatus,
|
||||
photoUrl,
|
||||
verificationId,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/// Represents the verification status of an attire item photo.
|
||||
enum AttireVerificationStatus {
|
||||
/// The photo is waiting for review.
|
||||
pending,
|
||||
|
||||
/// The photo was rejected.
|
||||
failed,
|
||||
|
||||
/// The photo was approved.
|
||||
success,
|
||||
}
|
||||
@@ -11,7 +11,12 @@ import 'src/domain/usecases/delete_hub_usecase.dart';
|
||||
import 'src/domain/usecases/get_hubs_usecase.dart';
|
||||
import 'src/domain/usecases/update_hub_usecase.dart';
|
||||
import 'src/presentation/blocs/client_hubs_bloc.dart';
|
||||
import 'src/presentation/blocs/edit_hub/edit_hub_bloc.dart';
|
||||
import 'src/presentation/blocs/hub_details/hub_details_bloc.dart';
|
||||
import 'src/presentation/pages/client_hubs_page.dart';
|
||||
import 'src/presentation/pages/edit_hub_page.dart';
|
||||
import 'src/presentation/pages/hub_details_page.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
export 'src/presentation/pages/client_hubs_page.dart';
|
||||
|
||||
@@ -34,10 +39,35 @@ class ClientHubsModule extends Module {
|
||||
|
||||
// BLoCs
|
||||
i.add<ClientHubsBloc>(ClientHubsBloc.new);
|
||||
i.add<EditHubBloc>(EditHubBloc.new);
|
||||
i.add<HubDetailsBloc>(HubDetailsBloc.new);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child(ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubs), child: (_) => const ClientHubsPage());
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubs),
|
||||
child: (_) => const ClientHubsPage(),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubDetails),
|
||||
child: (_) {
|
||||
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
|
||||
return HubDetailsPage(
|
||||
hub: data['hub'] as Hub,
|
||||
bloc: Modular.get<HubDetailsBloc>(),
|
||||
);
|
||||
},
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.editHub),
|
||||
child: (_) {
|
||||
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
|
||||
return EditHubPage(
|
||||
hub: data['hub'] as Hub?,
|
||||
bloc: Modular.get<EditHubBloc>(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,80 +2,36 @@ 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';
|
||||
import '../../domain/arguments/assign_nfc_tag_arguments.dart';
|
||||
import '../../domain/arguments/create_hub_arguments.dart';
|
||||
import '../../domain/arguments/delete_hub_arguments.dart';
|
||||
import '../../domain/usecases/assign_nfc_tag_usecase.dart';
|
||||
import '../../domain/usecases/create_hub_usecase.dart';
|
||||
import '../../domain/usecases/delete_hub_usecase.dart';
|
||||
import '../../domain/usecases/get_hubs_usecase.dart';
|
||||
import '../../domain/usecases/update_hub_usecase.dart';
|
||||
import 'client_hubs_event.dart';
|
||||
import 'client_hubs_state.dart';
|
||||
|
||||
/// BLoC responsible for managing the state of the Client Hubs feature.
|
||||
///
|
||||
/// It orchestrates the flow between the UI and the domain layer by invoking
|
||||
/// specific use cases for fetching, creating, deleting, and assigning tags to hubs.
|
||||
/// specific use cases for fetching hubs.
|
||||
class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
|
||||
with BlocErrorHandler<ClientHubsState>
|
||||
implements Disposable {
|
||||
|
||||
ClientHubsBloc({
|
||||
required GetHubsUseCase getHubsUseCase,
|
||||
required CreateHubUseCase createHubUseCase,
|
||||
required DeleteHubUseCase deleteHubUseCase,
|
||||
required AssignNfcTagUseCase assignNfcTagUseCase,
|
||||
required UpdateHubUseCase updateHubUseCase,
|
||||
}) : _getHubsUseCase = getHubsUseCase,
|
||||
_createHubUseCase = createHubUseCase,
|
||||
_deleteHubUseCase = deleteHubUseCase,
|
||||
_assignNfcTagUseCase = assignNfcTagUseCase,
|
||||
_updateHubUseCase = updateHubUseCase,
|
||||
super(const ClientHubsState()) {
|
||||
ClientHubsBloc({required GetHubsUseCase getHubsUseCase})
|
||||
: _getHubsUseCase = getHubsUseCase,
|
||||
super(const ClientHubsState()) {
|
||||
on<ClientHubsFetched>(_onFetched);
|
||||
on<ClientHubsAddRequested>(_onAddRequested);
|
||||
on<ClientHubsUpdateRequested>(_onUpdateRequested);
|
||||
on<ClientHubsDeleteRequested>(_onDeleteRequested);
|
||||
on<ClientHubsNfcTagAssignRequested>(_onNfcTagAssignRequested);
|
||||
on<ClientHubsMessageCleared>(_onMessageCleared);
|
||||
on<ClientHubsAddDialogToggled>(_onAddDialogToggled);
|
||||
on<ClientHubsIdentifyDialogToggled>(_onIdentifyDialogToggled);
|
||||
}
|
||||
|
||||
final GetHubsUseCase _getHubsUseCase;
|
||||
final CreateHubUseCase _createHubUseCase;
|
||||
final DeleteHubUseCase _deleteHubUseCase;
|
||||
final AssignNfcTagUseCase _assignNfcTagUseCase;
|
||||
final UpdateHubUseCase _updateHubUseCase;
|
||||
|
||||
void _onAddDialogToggled(
|
||||
ClientHubsAddDialogToggled event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) {
|
||||
emit(state.copyWith(showAddHubDialog: event.visible));
|
||||
}
|
||||
|
||||
void _onIdentifyDialogToggled(
|
||||
ClientHubsIdentifyDialogToggled event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) {
|
||||
if (event.hub == null) {
|
||||
emit(state.copyWith(clearHubToIdentify: true));
|
||||
} else {
|
||||
emit(state.copyWith(hubToIdentify: event.hub));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onFetched(
|
||||
ClientHubsFetched event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHubsStatus.loading));
|
||||
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<Hub> hubs = await _getHubsUseCase();
|
||||
final List<Hub> hubs = await _getHubsUseCase.call();
|
||||
emit(state.copyWith(status: ClientHubsStatus.success, hubs: hubs));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
@@ -85,143 +41,6 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onAddRequested(
|
||||
ClientHubsAddRequested event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _createHubUseCase(
|
||||
CreateHubArguments(
|
||||
name: event.name,
|
||||
address: event.address,
|
||||
placeId: event.placeId,
|
||||
latitude: event.latitude,
|
||||
longitude: event.longitude,
|
||||
city: event.city,
|
||||
state: event.state,
|
||||
street: event.street,
|
||||
country: event.country,
|
||||
zipCode: event.zipCode,
|
||||
costCenter: event.costCenter,
|
||||
),
|
||||
);
|
||||
final List<Hub> hubs = await _getHubsUseCase();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientHubsStatus.actionSuccess,
|
||||
hubs: hubs,
|
||||
successMessage: 'Hub created successfully',
|
||||
showAddHubDialog: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClientHubsStatus.actionFailure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onUpdateRequested(
|
||||
ClientHubsUpdateRequested event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _updateHubUseCase(
|
||||
UpdateHubArguments(
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
address: event.address,
|
||||
placeId: event.placeId,
|
||||
latitude: event.latitude,
|
||||
longitude: event.longitude,
|
||||
city: event.city,
|
||||
state: event.state,
|
||||
street: event.street,
|
||||
country: event.country,
|
||||
zipCode: event.zipCode,
|
||||
costCenter: event.costCenter,
|
||||
),
|
||||
);
|
||||
final List<Hub> hubs = await _getHubsUseCase();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientHubsStatus.actionSuccess,
|
||||
hubs: hubs,
|
||||
successMessage: 'Hub updated successfully!',
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClientHubsStatus.actionFailure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onDeleteRequested(
|
||||
ClientHubsDeleteRequested event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _deleteHubUseCase(DeleteHubArguments(hubId: event.hubId));
|
||||
final List<Hub> hubs = await _getHubsUseCase();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientHubsStatus.actionSuccess,
|
||||
hubs: hubs,
|
||||
successMessage: 'Hub deleted successfully',
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClientHubsStatus.actionFailure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onNfcTagAssignRequested(
|
||||
ClientHubsNfcTagAssignRequested event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _assignNfcTagUseCase(
|
||||
AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId),
|
||||
);
|
||||
final List<Hub> hubs = await _getHubsUseCase();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientHubsStatus.actionSuccess,
|
||||
hubs: hubs,
|
||||
successMessage: 'NFC tag assigned successfully',
|
||||
clearHubToIdentify: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClientHubsStatus.actionFailure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onMessageCleared(
|
||||
ClientHubsMessageCleared event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
@@ -231,8 +50,8 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
|
||||
clearErrorMessage: true,
|
||||
clearSuccessMessage: true,
|
||||
status:
|
||||
state.status == ClientHubsStatus.actionSuccess ||
|
||||
state.status == ClientHubsStatus.actionFailure
|
||||
state.status == ClientHubsStatus.success ||
|
||||
state.status == ClientHubsStatus.failure
|
||||
? ClientHubsStatus.success
|
||||
: state.status,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Base class for all client hubs events.
|
||||
abstract class ClientHubsEvent extends Equatable {
|
||||
@@ -14,142 +13,7 @@ class ClientHubsFetched extends ClientHubsEvent {
|
||||
const ClientHubsFetched();
|
||||
}
|
||||
|
||||
/// Event triggered to add a new hub.
|
||||
class ClientHubsAddRequested extends ClientHubsEvent {
|
||||
|
||||
const ClientHubsAddRequested({
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.placeId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.city,
|
||||
this.state,
|
||||
this.street,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
this.costCenter,
|
||||
});
|
||||
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;
|
||||
final String? costCenter;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
costCenter,
|
||||
];
|
||||
}
|
||||
|
||||
/// Event triggered to update an existing hub.
|
||||
class ClientHubsUpdateRequested extends ClientHubsEvent {
|
||||
const ClientHubsUpdateRequested({
|
||||
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,
|
||||
this.costCenter,
|
||||
});
|
||||
|
||||
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;
|
||||
final String? costCenter;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
costCenter,
|
||||
];
|
||||
}
|
||||
|
||||
/// Event triggered to delete a hub.
|
||||
class ClientHubsDeleteRequested extends ClientHubsEvent {
|
||||
|
||||
const ClientHubsDeleteRequested(this.hubId);
|
||||
final String hubId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[hubId];
|
||||
}
|
||||
|
||||
/// Event triggered to assign an NFC tag to a hub.
|
||||
class ClientHubsNfcTagAssignRequested extends ClientHubsEvent {
|
||||
|
||||
const ClientHubsNfcTagAssignRequested({
|
||||
required this.hubId,
|
||||
required this.nfcTagId,
|
||||
});
|
||||
final String hubId;
|
||||
final String nfcTagId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[hubId, nfcTagId];
|
||||
}
|
||||
|
||||
/// Event triggered to clear any error or success messages.
|
||||
class ClientHubsMessageCleared extends ClientHubsEvent {
|
||||
const ClientHubsMessageCleared();
|
||||
}
|
||||
|
||||
/// Event triggered to toggle the visibility of the "Add Hub" dialog.
|
||||
class ClientHubsAddDialogToggled extends ClientHubsEvent {
|
||||
|
||||
const ClientHubsAddDialogToggled({required this.visible});
|
||||
final bool visible;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[visible];
|
||||
}
|
||||
|
||||
/// Event triggered to toggle the visibility of the "Identify NFC" dialog.
|
||||
class ClientHubsIdentifyDialogToggled extends ClientHubsEvent {
|
||||
|
||||
const ClientHubsIdentifyDialogToggled({this.hub});
|
||||
final Hub? hub;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[hub];
|
||||
}
|
||||
|
||||
@@ -2,47 +2,27 @@ import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Enum representing the status of the client hubs state.
|
||||
enum ClientHubsStatus {
|
||||
initial,
|
||||
loading,
|
||||
success,
|
||||
failure,
|
||||
actionInProgress,
|
||||
actionSuccess,
|
||||
actionFailure,
|
||||
}
|
||||
enum ClientHubsStatus { initial, loading, success, failure }
|
||||
|
||||
/// State class for the ClientHubs BLoC.
|
||||
class ClientHubsState extends Equatable {
|
||||
|
||||
const ClientHubsState({
|
||||
this.status = ClientHubsStatus.initial,
|
||||
this.hubs = const <Hub>[],
|
||||
this.errorMessage,
|
||||
this.successMessage,
|
||||
this.showAddHubDialog = false,
|
||||
this.hubToIdentify,
|
||||
});
|
||||
|
||||
final ClientHubsStatus status;
|
||||
final List<Hub> hubs;
|
||||
final String? errorMessage;
|
||||
final String? successMessage;
|
||||
|
||||
/// Whether the "Add Hub" dialog should be visible.
|
||||
final bool showAddHubDialog;
|
||||
|
||||
/// The hub currently being identified/assigned an NFC tag.
|
||||
/// If null, the identification dialog is closed.
|
||||
final Hub? hubToIdentify;
|
||||
|
||||
ClientHubsState copyWith({
|
||||
ClientHubsStatus? status,
|
||||
List<Hub>? hubs,
|
||||
String? errorMessage,
|
||||
String? successMessage,
|
||||
bool? showAddHubDialog,
|
||||
Hub? hubToIdentify,
|
||||
bool clearHubToIdentify = false,
|
||||
bool clearErrorMessage = false,
|
||||
bool clearSuccessMessage = false,
|
||||
}) {
|
||||
@@ -55,10 +35,6 @@ class ClientHubsState extends Equatable {
|
||||
successMessage: clearSuccessMessage
|
||||
? null
|
||||
: (successMessage ?? this.successMessage),
|
||||
showAddHubDialog: showAddHubDialog ?? this.showAddHubDialog,
|
||||
hubToIdentify: clearHubToIdentify
|
||||
? null
|
||||
: (hubToIdentify ?? this.hubToIdentify),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +44,5 @@ class ClientHubsState extends Equatable {
|
||||
hubs,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
showAddHubDialog,
|
||||
hubToIdentify,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../../domain/arguments/create_hub_arguments.dart';
|
||||
import '../../../domain/usecases/create_hub_usecase.dart';
|
||||
import '../../../domain/usecases/update_hub_usecase.dart';
|
||||
import 'edit_hub_event.dart';
|
||||
import 'edit_hub_state.dart';
|
||||
|
||||
/// Bloc for creating and updating hubs.
|
||||
class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
|
||||
with BlocErrorHandler<EditHubState> {
|
||||
EditHubBloc({
|
||||
required CreateHubUseCase createHubUseCase,
|
||||
required UpdateHubUseCase updateHubUseCase,
|
||||
}) : _createHubUseCase = createHubUseCase,
|
||||
_updateHubUseCase = updateHubUseCase,
|
||||
super(const EditHubState()) {
|
||||
on<EditHubAddRequested>(_onAddRequested);
|
||||
on<EditHubUpdateRequested>(_onUpdateRequested);
|
||||
}
|
||||
|
||||
final CreateHubUseCase _createHubUseCase;
|
||||
final UpdateHubUseCase _updateHubUseCase;
|
||||
|
||||
Future<void> _onAddRequested(
|
||||
EditHubAddRequested event,
|
||||
Emitter<EditHubState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: EditHubStatus.loading));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _createHubUseCase.call(
|
||||
CreateHubArguments(
|
||||
name: event.name,
|
||||
address: event.address,
|
||||
placeId: event.placeId,
|
||||
latitude: event.latitude,
|
||||
longitude: event.longitude,
|
||||
city: event.city,
|
||||
state: event.state,
|
||||
street: event.street,
|
||||
country: event.country,
|
||||
zipCode: event.zipCode,
|
||||
),
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: EditHubStatus.success,
|
||||
successMessage: 'Hub created successfully',
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) =>
|
||||
state.copyWith(status: EditHubStatus.failure, errorMessage: errorKey),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onUpdateRequested(
|
||||
EditHubUpdateRequested event,
|
||||
Emitter<EditHubState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: EditHubStatus.loading));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _updateHubUseCase.call(
|
||||
UpdateHubArguments(
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
address: event.address,
|
||||
placeId: event.placeId,
|
||||
latitude: event.latitude,
|
||||
longitude: event.longitude,
|
||||
city: event.city,
|
||||
state: event.state,
|
||||
street: event.street,
|
||||
country: event.country,
|
||||
zipCode: event.zipCode,
|
||||
),
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: EditHubStatus.success,
|
||||
successMessage: 'Hub updated successfully',
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) =>
|
||||
state.copyWith(status: EditHubStatus.failure, errorMessage: errorKey),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Base class for all edit hub events.
|
||||
abstract class EditHubEvent extends Equatable {
|
||||
const EditHubEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Event triggered to add a new hub.
|
||||
class EditHubAddRequested extends EditHubEvent {
|
||||
const EditHubAddRequested({
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.placeId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.city,
|
||||
this.state,
|
||||
this.street,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
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?>[
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
|
||||
/// Event triggered to update an existing hub.
|
||||
class EditHubUpdateRequested extends EditHubEvent {
|
||||
const EditHubUpdateRequested({
|
||||
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,50 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Status of the edit hub operation.
|
||||
enum EditHubStatus {
|
||||
/// Initial state.
|
||||
initial,
|
||||
|
||||
/// Operation in progress.
|
||||
loading,
|
||||
|
||||
/// Operation succeeded.
|
||||
success,
|
||||
|
||||
/// Operation failed.
|
||||
failure,
|
||||
}
|
||||
|
||||
/// State for the edit hub operation.
|
||||
class EditHubState extends Equatable {
|
||||
const EditHubState({
|
||||
this.status = EditHubStatus.initial,
|
||||
this.errorMessage,
|
||||
this.successMessage,
|
||||
});
|
||||
|
||||
/// The status of the operation.
|
||||
final EditHubStatus status;
|
||||
|
||||
/// The error message if the operation failed.
|
||||
final String? errorMessage;
|
||||
|
||||
/// The success message if the operation succeeded.
|
||||
final String? successMessage;
|
||||
|
||||
/// Create a copy of this state with the given fields replaced.
|
||||
EditHubState copyWith({
|
||||
EditHubStatus? status,
|
||||
String? errorMessage,
|
||||
String? successMessage,
|
||||
}) {
|
||||
return EditHubState(
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
successMessage: successMessage ?? this.successMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[status, errorMessage, successMessage];
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../../domain/arguments/assign_nfc_tag_arguments.dart';
|
||||
import '../../../domain/arguments/delete_hub_arguments.dart';
|
||||
import '../../../domain/usecases/assign_nfc_tag_usecase.dart';
|
||||
import '../../../domain/usecases/delete_hub_usecase.dart';
|
||||
import 'hub_details_event.dart';
|
||||
import 'hub_details_state.dart';
|
||||
|
||||
/// Bloc for managing hub details and operations like delete and NFC assignment.
|
||||
class HubDetailsBloc extends Bloc<HubDetailsEvent, HubDetailsState>
|
||||
with BlocErrorHandler<HubDetailsState> {
|
||||
HubDetailsBloc({
|
||||
required DeleteHubUseCase deleteHubUseCase,
|
||||
required AssignNfcTagUseCase assignNfcTagUseCase,
|
||||
}) : _deleteHubUseCase = deleteHubUseCase,
|
||||
_assignNfcTagUseCase = assignNfcTagUseCase,
|
||||
super(const HubDetailsState()) {
|
||||
on<HubDetailsDeleteRequested>(_onDeleteRequested);
|
||||
on<HubDetailsNfcTagAssignRequested>(_onNfcTagAssignRequested);
|
||||
}
|
||||
|
||||
final DeleteHubUseCase _deleteHubUseCase;
|
||||
final AssignNfcTagUseCase _assignNfcTagUseCase;
|
||||
|
||||
Future<void> _onDeleteRequested(
|
||||
HubDetailsDeleteRequested event,
|
||||
Emitter<HubDetailsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: HubDetailsStatus.loading));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.id));
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: HubDetailsStatus.deleted,
|
||||
successMessage: 'Hub deleted successfully',
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: HubDetailsStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onNfcTagAssignRequested(
|
||||
HubDetailsNfcTagAssignRequested event,
|
||||
Emitter<HubDetailsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: HubDetailsStatus.loading));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _assignNfcTagUseCase.call(
|
||||
AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId),
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: HubDetailsStatus.success,
|
||||
successMessage: 'NFC tag assigned successfully',
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: HubDetailsStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Base class for all hub details events.
|
||||
abstract class HubDetailsEvent extends Equatable {
|
||||
const HubDetailsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Event triggered to delete a hub.
|
||||
class HubDetailsDeleteRequested extends HubDetailsEvent {
|
||||
const HubDetailsDeleteRequested(this.id);
|
||||
final String id;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id];
|
||||
}
|
||||
|
||||
/// Event triggered to assign an NFC tag to a hub.
|
||||
class HubDetailsNfcTagAssignRequested extends HubDetailsEvent {
|
||||
const HubDetailsNfcTagAssignRequested({
|
||||
required this.hubId,
|
||||
required this.nfcTagId,
|
||||
});
|
||||
|
||||
final String hubId;
|
||||
final String nfcTagId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[hubId, nfcTagId];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Status of the hub details operation.
|
||||
enum HubDetailsStatus {
|
||||
/// Initial state.
|
||||
initial,
|
||||
|
||||
/// Operation in progress.
|
||||
loading,
|
||||
|
||||
/// Operation succeeded.
|
||||
success,
|
||||
|
||||
/// Operation failed.
|
||||
failure,
|
||||
|
||||
/// Hub was deleted.
|
||||
deleted,
|
||||
}
|
||||
|
||||
/// State for the hub details operation.
|
||||
class HubDetailsState extends Equatable {
|
||||
const HubDetailsState({
|
||||
this.status = HubDetailsStatus.initial,
|
||||
this.errorMessage,
|
||||
this.successMessage,
|
||||
});
|
||||
|
||||
/// The status of the operation.
|
||||
final HubDetailsStatus status;
|
||||
|
||||
/// The error message if the operation failed.
|
||||
final String? errorMessage;
|
||||
|
||||
/// The success message if the operation succeeded.
|
||||
final String? successMessage;
|
||||
|
||||
/// Create a copy of this state with the given fields replaced.
|
||||
HubDetailsState copyWith({
|
||||
HubDetailsStatus? status,
|
||||
String? errorMessage,
|
||||
String? successMessage,
|
||||
}) {
|
||||
return HubDetailsState(
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
successMessage: successMessage ?? this.successMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[status, errorMessage, successMessage];
|
||||
}
|
||||
@@ -8,11 +8,10 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/client_hubs_bloc.dart';
|
||||
import '../blocs/client_hubs_event.dart';
|
||||
import '../blocs/client_hubs_state.dart';
|
||||
import '../widgets/add_hub_dialog.dart';
|
||||
|
||||
import '../widgets/hub_card.dart';
|
||||
import '../widgets/hub_empty_state.dart';
|
||||
import '../widgets/hub_info_card.dart';
|
||||
import '../widgets/identify_nfc_dialog.dart';
|
||||
|
||||
/// The main page for the client hubs feature.
|
||||
///
|
||||
@@ -43,7 +42,8 @@ class ClientHubsPage extends StatelessWidget {
|
||||
context,
|
||||
).add(const ClientHubsMessageCleared());
|
||||
}
|
||||
if (state.successMessage != null && state.successMessage!.isNotEmpty) {
|
||||
if (state.successMessage != null &&
|
||||
state.successMessage!.isNotEmpty) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.successMessage!,
|
||||
@@ -58,104 +58,67 @@ class ClientHubsPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(const ClientHubsAddDialogToggled(visible: true)),
|
||||
onPressed: () async {
|
||||
final bool? success = await Modular.to.toEditHub();
|
||||
if (success == true && context.mounted) {
|
||||
BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(const ClientHubsFetched());
|
||||
}
|
||||
},
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: const Icon(UiIcons.add),
|
||||
),
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
_buildAppBar(context),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
vertical: UiConstants.space5,
|
||||
).copyWith(bottom: 100),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate(<Widget>[
|
||||
if (state.status == ClientHubsStatus.loading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (state.hubs.isEmpty)
|
||||
HubEmptyState(
|
||||
onAddPressed: () =>
|
||||
BlocProvider.of<ClientHubsBloc>(context).add(
|
||||
const ClientHubsAddDialogToggled(
|
||||
visible: true,
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...<Widget>[
|
||||
...state.hubs.map(
|
||||
(Hub hub) => HubCard(
|
||||
hub: hub,
|
||||
onNfcPressed: () =>
|
||||
BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(
|
||||
ClientHubsIdentifyDialogToggled(hub: hub),
|
||||
),
|
||||
onDeletePressed: () => _confirmDeleteHub(
|
||||
context,
|
||||
hub,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
const HubInfoCard(),
|
||||
]),
|
||||
body: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
_buildAppBar(context),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
vertical: UiConstants.space5,
|
||||
).copyWith(bottom: 100),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate(<Widget>[
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: UiConstants.space5),
|
||||
child: HubInfoCard(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (state.status == ClientHubsStatus.loading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (state.hubs.isEmpty)
|
||||
HubEmptyState(
|
||||
onAddPressed: () async {
|
||||
final bool? success = await Modular.to.toEditHub();
|
||||
if (success == true && context.mounted) {
|
||||
BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(const ClientHubsFetched());
|
||||
}
|
||||
},
|
||||
)
|
||||
else ...<Widget>[
|
||||
...state.hubs.map(
|
||||
(Hub hub) => HubCard(
|
||||
hub: hub,
|
||||
onTap: () async {
|
||||
final bool? success = await Modular.to
|
||||
.toHubDetails(hub);
|
||||
if (success == true && context.mounted) {
|
||||
BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(const ClientHubsFetched());
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (state.showAddHubDialog)
|
||||
AddHubDialog(
|
||||
onCreate: (
|
||||
String name,
|
||||
String address, {
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) {
|
||||
BlocProvider.of<ClientHubsBloc>(context).add(
|
||||
ClientHubsAddRequested(
|
||||
name: name,
|
||||
address: address,
|
||||
placeId: placeId,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
);
|
||||
},
|
||||
onCancel: () => BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(const ClientHubsAddDialogToggled(visible: false)),
|
||||
),
|
||||
if (state.hubToIdentify != null)
|
||||
IdentifyNfcDialog(
|
||||
hub: state.hubToIdentify!,
|
||||
onAssign: (String tagId) {
|
||||
BlocProvider.of<ClientHubsBloc>(context).add(
|
||||
ClientHubsNfcTagAssignRequested(
|
||||
hubId: state.hubToIdentify!.id,
|
||||
nfcTagId: tagId,
|
||||
),
|
||||
);
|
||||
},
|
||||
onCancel: () => BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(const ClientHubsIdentifyDialogToggled()),
|
||||
),
|
||||
if (state.status == ClientHubsStatus.actionInProgress)
|
||||
Container(
|
||||
color: UiColors.black.withValues(alpha: 0.1),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -166,7 +129,7 @@ class ClientHubsPage extends StatelessWidget {
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
backgroundColor: UiColors.foreground, // Dark Slate equivalent
|
||||
backgroundColor: UiColors.foreground,
|
||||
automaticallyImplyLeading: false,
|
||||
expandedHeight: 140,
|
||||
pinned: true,
|
||||
@@ -225,51 +188,4 @@ class ClientHubsPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDeleteHub(BuildContext context, Hub hub) async {
|
||||
final String hubName = hub.name.isEmpty ? t.client_hubs.title : hub.name;
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(t.client_hubs.delete_dialog.title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(t.client_hubs.delete_dialog.message(hubName: hubName)),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(t.client_hubs.delete_dialog.undo_warning),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
t.client_hubs.delete_dialog.dependency_warning,
|
||||
style: UiTypography.footnote1r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Modular.to.pop(),
|
||||
child: Text(t.client_hubs.delete_dialog.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(ClientHubsDeleteRequested(hub.id));
|
||||
Modular.to.pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
),
|
||||
child: Text(t.client_hubs.delete_dialog.delete),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,21 @@ 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:google_places_flutter/model/prediction.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../blocs/client_hubs_bloc.dart';
|
||||
import '../blocs/client_hubs_event.dart';
|
||||
import '../blocs/client_hubs_state.dart';
|
||||
import '../widgets/hub_address_autocomplete.dart';
|
||||
import '../blocs/edit_hub/edit_hub_bloc.dart';
|
||||
import '../blocs/edit_hub/edit_hub_event.dart';
|
||||
import '../blocs/edit_hub/edit_hub_state.dart';
|
||||
import '../widgets/edit_hub/edit_hub_form_section.dart';
|
||||
|
||||
/// A dedicated full-screen page for editing an existing hub.
|
||||
///
|
||||
/// Takes the parent [ClientHubsBloc] via [BlocProvider.value] so the
|
||||
/// updated hub list is reflected on the hubs list page when the user
|
||||
/// saves and navigates back.
|
||||
/// A dedicated full-screen page for adding or editing a hub.
|
||||
class EditHubPage extends StatefulWidget {
|
||||
const EditHubPage({
|
||||
required this.hub,
|
||||
required this.bloc,
|
||||
super.key,
|
||||
});
|
||||
const EditHubPage({this.hub, required this.bloc, super.key});
|
||||
|
||||
final Hub hub;
|
||||
final ClientHubsBloc bloc;
|
||||
final Hub? hub;
|
||||
final EditHubBloc bloc;
|
||||
|
||||
@override
|
||||
State<EditHubPage> createState() => _EditHubPageState();
|
||||
@@ -32,7 +25,6 @@ class EditHubPage extends StatefulWidget {
|
||||
class _EditHubPageState extends State<EditHubPage> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _costCenterController;
|
||||
late final TextEditingController _addressController;
|
||||
late final FocusNode _addressFocusNode;
|
||||
Prediction? _selectedPrediction;
|
||||
@@ -40,16 +32,18 @@ class _EditHubPageState extends State<EditHubPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.hub.name);
|
||||
_costCenterController = TextEditingController(text: widget.hub.costCenter);
|
||||
_addressController = TextEditingController(text: widget.hub.address);
|
||||
_nameController = TextEditingController(text: widget.hub?.name);
|
||||
_addressController = TextEditingController(text: widget.hub?.address);
|
||||
_addressFocusNode = FocusNode();
|
||||
|
||||
// Update header on change (if header is added back)
|
||||
_nameController.addListener(() => setState(() {}));
|
||||
_addressController.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_costCenterController.dispose();
|
||||
_addressController.dispose();
|
||||
_addressFocusNode.dispose();
|
||||
super.dispose();
|
||||
@@ -67,38 +61,50 @@ class _EditHubPageState extends State<EditHubPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
ReadContext(context).read<ClientHubsBloc>().add(
|
||||
ClientHubsUpdateRequested(
|
||||
id: widget.hub.id,
|
||||
name: _nameController.text.trim(),
|
||||
address: _addressController.text.trim(),
|
||||
placeId: _selectedPrediction?.placeId,
|
||||
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
|
||||
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
|
||||
costCenter: _costCenterController.text.trim().isEmpty ? null : _costCenterController.text.trim(),
|
||||
),
|
||||
);
|
||||
if (widget.hub == null) {
|
||||
widget.bloc.add(
|
||||
EditHubAddRequested(
|
||||
name: _nameController.text.trim(),
|
||||
address: _addressController.text.trim(),
|
||||
placeId: _selectedPrediction?.placeId,
|
||||
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
|
||||
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
widget.bloc.add(
|
||||
EditHubUpdateRequested(
|
||||
id: widget.hub!.id,
|
||||
name: _nameController.text.trim(),
|
||||
address: _addressController.text.trim(),
|
||||
placeId: _selectedPrediction?.placeId,
|
||||
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
|
||||
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ClientHubsBloc>.value(
|
||||
return BlocProvider<EditHubBloc>.value(
|
||||
value: widget.bloc,
|
||||
child: BlocListener<ClientHubsBloc, ClientHubsState>(
|
||||
listenWhen: (ClientHubsState prev, ClientHubsState curr) =>
|
||||
prev.status != curr.status || prev.successMessage != curr.successMessage,
|
||||
listener: (BuildContext context, ClientHubsState state) {
|
||||
if (state.status == ClientHubsStatus.actionSuccess &&
|
||||
child: BlocListener<EditHubBloc, EditHubState>(
|
||||
listenWhen: (EditHubState prev, EditHubState curr) =>
|
||||
prev.status != curr.status ||
|
||||
prev.successMessage != curr.successMessage,
|
||||
listener: (BuildContext context, EditHubState state) {
|
||||
if (state.status == EditHubStatus.success &&
|
||||
state.successMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.successMessage!,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
// Pop back to details page with updated hub
|
||||
Navigator.of(context).pop(true);
|
||||
// Pop back to the previous screen.
|
||||
Modular.to.pop(true);
|
||||
}
|
||||
if (state.status == ClientHubsStatus.actionFailure &&
|
||||
if (state.status == EditHubStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
@@ -107,98 +113,43 @@ class _EditHubPageState extends State<EditHubPage> {
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<ClientHubsBloc, ClientHubsState>(
|
||||
builder: (BuildContext context, ClientHubsState state) {
|
||||
final bool isSaving =
|
||||
state.status == ClientHubsStatus.actionInProgress;
|
||||
child: BlocBuilder<EditHubBloc, EditHubState>(
|
||||
builder: (BuildContext context, EditHubState state) {
|
||||
final bool isSaving = state.status == EditHubStatus.loading;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
appBar: AppBar(
|
||||
backgroundColor: UiColors.foreground,
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.client_hubs.edit_hub.title,
|
||||
style: UiTypography.headline3m.white,
|
||||
),
|
||||
Text(
|
||||
t.client_hubs.edit_hub.subtitle,
|
||||
style: UiTypography.footnote1r.copyWith(
|
||||
color: UiColors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: UiAppBar(
|
||||
title: widget.hub == null
|
||||
? t.client_hubs.add_hub_dialog.title
|
||||
: t.client_hubs.edit_hub.title,
|
||||
subtitle: widget.hub == null
|
||||
? t.client_hubs.add_hub_dialog.create_button
|
||||
: t.client_hubs.edit_hub.subtitle,
|
||||
onLeadingPressed: () => Modular.to.pop(),
|
||||
),
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
// ── Name field ──────────────────────────────────
|
||||
_FieldLabel(t.client_hubs.edit_hub.name_label),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
style: UiTypography.body1r.textPrimary,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Name is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: _inputDecoration(
|
||||
t.client_hubs.edit_hub.name_hint,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// ── Cost Center field ────────────────────────────
|
||||
_FieldLabel(t.client_hubs.edit_hub.cost_center_label),
|
||||
TextFormField(
|
||||
controller: _costCenterController,
|
||||
style: UiTypography.body1r.textPrimary,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: _inputDecoration(
|
||||
t.client_hubs.edit_hub.cost_center_hint,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// ── Address field ────────────────────────────────
|
||||
_FieldLabel(t.client_hubs.edit_hub.address_label),
|
||||
HubAddressAutocomplete(
|
||||
controller: _addressController,
|
||||
hintText: t.client_hubs.edit_hub.address_hint,
|
||||
focusNode: _addressFocusNode,
|
||||
onSelected: (Prediction prediction) {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: EditHubFormSection(
|
||||
formKey: _formKey,
|
||||
nameController: _nameController,
|
||||
addressController: _addressController,
|
||||
addressFocusNode: _addressFocusNode,
|
||||
onAddressSelected: (Prediction prediction) {
|
||||
_selectedPrediction = prediction;
|
||||
},
|
||||
onSave: _onSave,
|
||||
isSaving: isSaving,
|
||||
isEdit: widget.hub != null,
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// ── Save button ──────────────────────────────────
|
||||
UiButton.primary(
|
||||
onPressed: isSaving ? null : _onSave,
|
||||
text: t.client_hubs.edit_hub.save_button,
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -216,42 +167,4 @@ class _EditHubPageState extends State<EditHubPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration(String hint) {
|
||||
return InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: UiTypography.body2r.textPlaceholder,
|
||||
filled: true,
|
||||
fillColor: UiColors.input,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: 14,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderSide: const BorderSide(color: UiColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderSide: const BorderSide(color: UiColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderSide: const BorderSide(color: UiColors.ring, width: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FieldLabel extends StatelessWidget {
|
||||
const _FieldLabel(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
||||
child: Text(text, style: UiTypography.body2m.textPrimary),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +1,134 @@
|
||||
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';
|
||||
|
||||
import '../blocs/client_hubs_bloc.dart';
|
||||
import 'edit_hub_page.dart';
|
||||
import '../blocs/hub_details/hub_details_bloc.dart';
|
||||
import '../blocs/hub_details/hub_details_event.dart';
|
||||
import '../blocs/hub_details/hub_details_state.dart';
|
||||
import '../widgets/hub_details/hub_details_bottom_actions.dart';
|
||||
import '../widgets/hub_details/hub_details_header.dart';
|
||||
import '../widgets/hub_details/hub_details_item.dart';
|
||||
|
||||
/// A read-only details page for a single [Hub].
|
||||
///
|
||||
/// Shows hub name, address, and NFC tag assignment.
|
||||
/// Tapping the edit button navigates to [EditHubPage] (a dedicated page,
|
||||
/// not a dialog), satisfying the "separate edit hub page" acceptance criterion.
|
||||
class HubDetailsPage extends StatelessWidget {
|
||||
const HubDetailsPage({
|
||||
required this.hub,
|
||||
required this.bloc,
|
||||
super.key,
|
||||
});
|
||||
const HubDetailsPage({required this.hub, required this.bloc, super.key});
|
||||
|
||||
final Hub hub;
|
||||
final ClientHubsBloc bloc;
|
||||
final HubDetailsBloc bloc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(hub.name),
|
||||
backgroundColor: UiColors.foreground,
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton.icon(
|
||||
onPressed: () => _navigateToEditPage(context),
|
||||
icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16),
|
||||
label: Text(
|
||||
t.client_hubs.hub_details.edit_button,
|
||||
style: const TextStyle(color: UiColors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_buildDetailItem(
|
||||
label: t.client_hubs.hub_details.name_label,
|
||||
value: hub.name,
|
||||
icon: UiIcons.home,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildDetailItem(
|
||||
label: t.client_hubs.hub_details.cost_center_label,
|
||||
value: hub.costCenter?.isNotEmpty == true
|
||||
? hub.costCenter!
|
||||
: t.client_hubs.hub_details.cost_center_none,
|
||||
icon: UiIcons.dollarSign, // or UiIcons.building, hash, etc.
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildDetailItem(
|
||||
label: t.client_hubs.hub_details.address_label,
|
||||
value: hub.address,
|
||||
icon: UiIcons.mapPin,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildDetailItem(
|
||||
label: t.client_hubs.hub_details.nfc_label,
|
||||
value: hub.nfcTagId ?? t.client_hubs.hub_details.nfc_not_assigned,
|
||||
icon: UiIcons.nfc,
|
||||
isHighlight: hub.nfcTagId != null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return BlocProvider<HubDetailsBloc>.value(
|
||||
value: bloc,
|
||||
child: BlocListener<HubDetailsBloc, HubDetailsState>(
|
||||
listener: (BuildContext context, HubDetailsState state) {
|
||||
if (state.status == HubDetailsStatus.deleted) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.successMessage ?? 'Hub deleted successfully',
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Modular.to.pop(true); // Return true to indicate change
|
||||
}
|
||||
if (state.status == HubDetailsStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.errorMessage!,
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<HubDetailsBloc, HubDetailsState>(
|
||||
builder: (BuildContext context, HubDetailsState state) {
|
||||
final bool isLoading = state.status == HubDetailsStatus.loading;
|
||||
|
||||
Widget _buildDetailItem({
|
||||
required String label,
|
||||
required String value,
|
||||
required IconData icon,
|
||||
bool isHighlight = false,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
boxShadow: const <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.popupShadow,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: isHighlight ? UiColors.tagInProgress : UiColors.bgInputField,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isHighlight ? UiColors.iconSuccess : UiColors.iconPrimary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(label, style: UiTypography.footnote1r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(value, style: UiTypography.body1m.textPrimary),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
return Scaffold(
|
||||
appBar: const UiAppBar(showBackButton: true),
|
||||
bottomNavigationBar: HubDetailsBottomActions(
|
||||
isLoading: isLoading,
|
||||
onDelete: () => _confirmDeleteHub(context),
|
||||
onEdit: () => _navigateToEditPage(context),
|
||||
),
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
// ── Header ──────────────────────────────────────────
|
||||
HubDetailsHeader(hub: hub),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
HubDetailsItem(
|
||||
label: t.client_hubs.hub_details.nfc_label,
|
||||
value:
|
||||
hub.nfcTagId ??
|
||||
t.client_hubs.hub_details.nfc_not_assigned,
|
||||
icon: UiIcons.nfc,
|
||||
isHighlight: hub.nfcTagId != null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isLoading)
|
||||
Container(
|
||||
color: UiColors.black.withValues(alpha: 0.1),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _navigateToEditPage(BuildContext context) async {
|
||||
// Navigate to the dedicated edit page and await result.
|
||||
// If the page returns `true` (save succeeded), pop the details page too so
|
||||
// the user sees the refreshed hub list (the BLoC already holds updated data).
|
||||
final bool? saved = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(
|
||||
builder: (_) => EditHubPage(hub: hub, bloc: bloc),
|
||||
final bool? saved = await Modular.to.toEditHub(hub: hub);
|
||||
if (saved == true && context.mounted) {
|
||||
Modular.to.pop(true); // Return true to indicate change
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDeleteHub(BuildContext context) async {
|
||||
final bool? confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: Text(t.client_hubs.delete_dialog.title),
|
||||
content: Text(t.client_hubs.delete_dialog.message(hubName: hub.name)),
|
||||
actions: <Widget>[
|
||||
UiButton.text(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(t.client_hubs.delete_dialog.cancel),
|
||||
),
|
||||
UiButton.text(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: UiColors.destructive),
|
||||
child: Text(t.client_hubs.delete_dialog.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (saved == true && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
if (confirm == true) {
|
||||
bloc.add(HubDetailsDeleteRequested(hub.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:google_places_flutter/model/prediction.dart';
|
||||
|
||||
import 'hub_address_autocomplete.dart';
|
||||
|
||||
/// A dialog for adding a new hub.
|
||||
class AddHubDialog extends StatefulWidget {
|
||||
|
||||
/// Creates an [AddHubDialog].
|
||||
const AddHubDialog({
|
||||
required this.onCreate,
|
||||
required this.onCancel,
|
||||
super.key,
|
||||
});
|
||||
/// Callback when the "Create Hub" button is pressed.
|
||||
final void Function(
|
||||
String name,
|
||||
String address, {
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? costCenter,
|
||||
}) onCreate;
|
||||
|
||||
/// Callback when the dialog is cancelled.
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@override
|
||||
State<AddHubDialog> createState() => _AddHubDialogState();
|
||||
}
|
||||
|
||||
class _AddHubDialogState extends State<AddHubDialog> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _costCenterController;
|
||||
late final TextEditingController _addressController;
|
||||
late final FocusNode _addressFocusNode;
|
||||
Prediction? _selectedPrediction;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController();
|
||||
_costCenterController = TextEditingController();
|
||||
_addressController = TextEditingController();
|
||||
_addressFocusNode = FocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_costCenterController.dispose();
|
||||
_addressController.dispose();
|
||||
_addressFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: UiColors.bgOverlay,
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgPopup,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
boxShadow: const <BoxShadow>[
|
||||
BoxShadow(color: UiColors.popupShadow, blurRadius: 20),
|
||||
],
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.client_hubs.add_hub_dialog.title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
_buildFieldLabel(t.client_hubs.add_hub_dialog.name_label),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
style: UiTypography.body1r.textPrimary,
|
||||
validator: (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Name is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: _buildInputDecoration(
|
||||
t.client_hubs.add_hub_dialog.name_hint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildFieldLabel(t.client_hubs.add_hub_dialog.cost_center_label),
|
||||
TextFormField(
|
||||
controller: _costCenterController,
|
||||
style: UiTypography.body1r.textPrimary,
|
||||
decoration: _buildInputDecoration(
|
||||
t.client_hubs.add_hub_dialog.cost_center_hint,
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildFieldLabel(t.client_hubs.add_hub_dialog.address_label),
|
||||
// Assuming HubAddressAutocomplete is a custom widget wrapper.
|
||||
// If it doesn't expose a validator, we might need to modify it or manually check _addressController.
|
||||
// For now, let's just make sure we validate name. Address is tricky if it's a wrapper.
|
||||
HubAddressAutocomplete(
|
||||
controller: _addressController,
|
||||
hintText: t.client_hubs.add_hub_dialog.address_hint,
|
||||
focusNode: _addressFocusNode,
|
||||
onSelected: (Prediction prediction) {
|
||||
_selectedPrediction = prediction;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
onPressed: widget.onCancel,
|
||||
text: t.common.cancel,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Manually check address if needed, or assume manual entry is ok.
|
||||
if (_addressController.text.trim().isEmpty) {
|
||||
// Show manual error or scaffold
|
||||
UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
widget.onCreate(
|
||||
_nameController.text,
|
||||
_addressController.text,
|
||||
placeId: _selectedPrediction?.placeId,
|
||||
latitude: double.tryParse(
|
||||
_selectedPrediction?.lat ?? '',
|
||||
),
|
||||
longitude: double.tryParse(
|
||||
_selectedPrediction?.lng ?? '',
|
||||
),
|
||||
costCenter: _costCenterController.text.trim().isEmpty ? null : _costCenterController.text.trim(),
|
||||
);
|
||||
}
|
||||
},
|
||||
text: t.client_hubs.add_hub_dialog.create_button,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFieldLabel(String label) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
||||
child: Text(label, style: UiTypography.body2m.textPrimary),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _buildInputDecoration(String hint) {
|
||||
return InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: UiTypography.body2r.textPlaceholder,
|
||||
filled: true,
|
||||
fillColor: UiColors.input,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: 14,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderSide: const BorderSide(color: UiColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderSide: const BorderSide(color: UiColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderSide: const BorderSide(color: UiColors.ring, width: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A simple field label widget for the edit hub page.
|
||||
class EditHubFieldLabel extends StatelessWidget {
|
||||
const EditHubFieldLabel(this.text, {super.key});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
||||
child: Text(text, style: UiTypography.body2m.textPrimary),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_places_flutter/model/prediction.dart';
|
||||
|
||||
import '../hub_address_autocomplete.dart';
|
||||
import 'edit_hub_field_label.dart';
|
||||
|
||||
/// The form section for adding or editing a hub.
|
||||
class EditHubFormSection extends StatelessWidget {
|
||||
const EditHubFormSection({
|
||||
required this.formKey,
|
||||
required this.nameController,
|
||||
required this.addressController,
|
||||
required this.addressFocusNode,
|
||||
required this.onAddressSelected,
|
||||
required this.onSave,
|
||||
this.isSaving = false,
|
||||
this.isEdit = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController addressController;
|
||||
final FocusNode addressFocusNode;
|
||||
final ValueChanged<Prediction> onAddressSelected;
|
||||
final VoidCallback onSave;
|
||||
final bool isSaving;
|
||||
final bool isEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
// ── Name field ──────────────────────────────────
|
||||
EditHubFieldLabel(t.client_hubs.edit_hub.name_label),
|
||||
TextFormField(
|
||||
controller: nameController,
|
||||
style: UiTypography.body1r.textPrimary,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Name is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: _inputDecoration(t.client_hubs.edit_hub.name_hint),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// ── Address field ────────────────────────────────
|
||||
EditHubFieldLabel(t.client_hubs.edit_hub.address_label),
|
||||
HubAddressAutocomplete(
|
||||
controller: addressController,
|
||||
hintText: t.client_hubs.edit_hub.address_hint,
|
||||
focusNode: addressFocusNode,
|
||||
onSelected: onAddressSelected,
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// ── Save button ──────────────────────────────────
|
||||
UiButton.primary(
|
||||
onPressed: isSaving ? null : onSave,
|
||||
text: isEdit
|
||||
? t.client_hubs.edit_hub.save_button
|
||||
: t.client_hubs.add_hub_dialog.create_button,
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration(String hint) {
|
||||
return InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: UiTypography.body2r.textPlaceholder,
|
||||
filled: true,
|
||||
fillColor: UiColors.input,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: 14,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderSide: const BorderSide(color: UiColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderSide: const BorderSide(color: UiColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderSide: const BorderSide(color: UiColors.ring, width: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,115 +5,95 @@ import 'package:core_localization/core_localization.dart';
|
||||
|
||||
/// A card displaying information about a single hub.
|
||||
class HubCard extends StatelessWidget {
|
||||
|
||||
/// Creates a [HubCard].
|
||||
const HubCard({
|
||||
required this.hub,
|
||||
required this.onNfcPressed,
|
||||
required this.onDeletePressed,
|
||||
super.key,
|
||||
});
|
||||
const HubCard({required this.hub, required this.onTap, super.key});
|
||||
|
||||
/// The hub to display.
|
||||
final Hub hub;
|
||||
|
||||
/// Callback when the NFC button is pressed.
|
||||
final VoidCallback onNfcPressed;
|
||||
|
||||
/// Callback when the delete button is pressed.
|
||||
final VoidCallback onDeletePressed;
|
||||
/// Callback when the card is tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasNfc = hub.nfcTagId != null;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
boxShadow: const <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.popupShadow,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagInProgress,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagInProgress,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: Icon(
|
||||
hasNfc ? UiIcons.success : UiIcons.nfc,
|
||||
color: hasNfc ? UiColors.iconSuccess : UiColors.iconThird,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
hasNfc ? UiIcons.success : UiIcons.nfc,
|
||||
color: hasNfc ? UiColors.iconSuccess : UiColors.iconThird,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(hub.name, style: UiTypography.body1b.textPrimary),
|
||||
if (hub.address.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 12,
|
||||
color: UiColors.iconThird,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hub.address,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(hub.name, style: UiTypography.body1b.textPrimary),
|
||||
if (hub.address.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 12,
|
||||
color: UiColors.iconThird,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasNfc)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Text(
|
||||
t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!),
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: UiColors.textSuccess,
|
||||
fontFamily: 'monospace',
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Flexible(
|
||||
child: Text(
|
||||
hub.address,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
onPressed: onDeletePressed,
|
||||
icon: const Icon(
|
||||
UiIcons.delete,
|
||||
color: UiColors.destructive,
|
||||
size: 20,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
splashRadius: 20,
|
||||
if (hasNfc)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Text(
|
||||
t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!),
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: UiColors.textSuccess,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Icon(
|
||||
UiIcons.chevronRight,
|
||||
size: 16,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Bottom action buttons for the hub details page.
|
||||
class HubDetailsBottomActions extends StatelessWidget {
|
||||
const HubDetailsBottomActions({
|
||||
required this.onDelete,
|
||||
required this.onEdit,
|
||||
this.isLoading = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onEdit;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
onPressed: isLoading ? null : onDelete,
|
||||
text: t.common.delete,
|
||||
leadingIcon: UiIcons.delete,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
side: const BorderSide(color: UiColors.destructive),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
onPressed: isLoading ? null : onEdit,
|
||||
text: t.client_hubs.hub_details.edit_button,
|
||||
leadingIcon: UiIcons.edit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Header widget for the hub details page.
|
||||
class HubDetailsHeader extends StatelessWidget {
|
||||
const HubDetailsHeader({required this.hub, super.key});
|
||||
|
||||
final Hub hub;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
Text(hub.name, style: UiTypography.headline1b.textPrimary),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 16,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hub.address,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A reusable detail item for the hub details page.
|
||||
class HubDetailsItem extends StatelessWidget {
|
||||
const HubDetailsItem({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
this.isHighlight = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final bool isHighlight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: isHighlight
|
||||
? UiColors.tagInProgress
|
||||
: UiColors.bgInputField,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isHighlight ? UiColors.iconSuccess : UiColors.iconThird,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(label, style: UiTypography.footnote1r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(value, style: UiTypography.body1m.textPrimary),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,7 @@ class HubInfoCard extends StatelessWidget {
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
t.client_hubs.about_hubs.description,
|
||||
style: UiTypography.footnote1r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ 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/client_settings_bloc.dart';
|
||||
|
||||
/// A widget that displays the primary actions for the settings page.
|
||||
@@ -27,10 +28,6 @@ class SettingsActions extends StatelessWidget {
|
||||
_QuickLinksCard(labels: labels),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Notifications section
|
||||
_NotificationsSettingsCard(),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Log Out button (outlined)
|
||||
BlocBuilder<ClientSettingsBloc, ClientSettingsState>(
|
||||
builder: (BuildContext context, ClientSettingsState state) {
|
||||
@@ -80,15 +77,14 @@ class SettingsActions extends StatelessWidget {
|
||||
|
||||
/// Handles the sign-out button click event.
|
||||
void _onSignoutClicked(BuildContext context) {
|
||||
ReadContext(context)
|
||||
.read<ClientSettingsBloc>()
|
||||
.add(const ClientSettingsSignOutRequested());
|
||||
ReadContext(
|
||||
context,
|
||||
).read<ClientSettingsBloc>().add(const ClientSettingsSignOutRequested());
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick Links card — inline here since it's always part of SettingsActions ordering.
|
||||
class _QuickLinksCard extends StatelessWidget {
|
||||
|
||||
const _QuickLinksCard({required this.labels});
|
||||
final TranslationsClientSettingsProfileEn labels;
|
||||
|
||||
@@ -130,7 +126,6 @@ class _QuickLinksCard extends StatelessWidget {
|
||||
|
||||
/// A single quick link row item.
|
||||
class _QuickLinkItem extends StatelessWidget {
|
||||
|
||||
const _QuickLinkItem({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
@@ -198,24 +193,36 @@ class _NotificationsSettingsCard extends StatelessWidget {
|
||||
icon: UiIcons.bell,
|
||||
title: context.t.client_settings.preferences.push,
|
||||
value: state.pushEnabled,
|
||||
onChanged: (val) => ReadContext(context).read<ClientSettingsBloc>().add(
|
||||
ClientSettingsNotificationToggled(type: 'push', isEnabled: val),
|
||||
onChanged: (val) =>
|
||||
ReadContext(context).read<ClientSettingsBloc>().add(
|
||||
ClientSettingsNotificationToggled(
|
||||
type: 'push',
|
||||
isEnabled: val,
|
||||
),
|
||||
),
|
||||
),
|
||||
_NotificationToggle(
|
||||
icon: UiIcons.mail,
|
||||
title: context.t.client_settings.preferences.email,
|
||||
value: state.emailEnabled,
|
||||
onChanged: (val) => ReadContext(context).read<ClientSettingsBloc>().add(
|
||||
ClientSettingsNotificationToggled(type: 'email', isEnabled: val),
|
||||
onChanged: (val) =>
|
||||
ReadContext(context).read<ClientSettingsBloc>().add(
|
||||
ClientSettingsNotificationToggled(
|
||||
type: 'email',
|
||||
isEnabled: val,
|
||||
),
|
||||
),
|
||||
),
|
||||
_NotificationToggle(
|
||||
icon: UiIcons.phone,
|
||||
title: context.t.client_settings.preferences.sms,
|
||||
value: state.smsEnabled,
|
||||
onChanged: (val) => ReadContext(context).read<ClientSettingsBloc>().add(
|
||||
ClientSettingsNotificationToggled(type: 'sms', isEnabled: val),
|
||||
onChanged: (val) =>
|
||||
ReadContext(context).read<ClientSettingsBloc>().add(
|
||||
ClientSettingsNotificationToggled(
|
||||
type: 'sms',
|
||||
isEnabled: val,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -12,7 +12,8 @@ class SettingsProfileHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientSettingsProfileEn labels = t.client_settings.profile;
|
||||
final TranslationsClientSettingsProfileEn labels =
|
||||
t.client_settings.profile;
|
||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
||||
final String businessName =
|
||||
session?.business?.businessName ?? 'Your Company';
|
||||
@@ -26,9 +27,7 @@ class SettingsProfileHeader extends StatelessWidget {
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.only(bottom: 36),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
decoration: const BoxDecoration(color: UiColors.primary),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
@@ -75,13 +74,6 @@ class SettingsProfileHeader extends StatelessWidget {
|
||||
color: UiColors.white.withValues(alpha: 0.6),
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: photoUrl != null && photoUrl.isNotEmpty
|
||||
@@ -103,9 +95,7 @@ class SettingsProfileHeader extends StatelessWidget {
|
||||
// ── Business Name ─────────────────────────────────
|
||||
Text(
|
||||
businessName,
|
||||
style: UiTypography.headline3m.copyWith(
|
||||
color: UiColors.white,
|
||||
),
|
||||
style: UiTypography.headline3m.copyWith(color: UiColors.white),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
@@ -128,21 +118,6 @@ class SettingsProfileHeader extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 100),
|
||||
child: UiButton.secondary(
|
||||
text: labels.edit_profile,
|
||||
size: UiButtonSize.small,
|
||||
onPressed: () =>
|
||||
Modular.to.pushNamed('${ClientPaths.settings}/edit-profile'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.white,
|
||||
side: const BorderSide(color: UiColors.white, width: 1.5),
|
||||
backgroundColor: UiColors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -121,26 +121,14 @@ class HomeRepositoryImpl
|
||||
.listBenefitsDataByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final List<Benefit> results = response.data.benefitsDatas.map((data) {
|
||||
return response.data.benefitsDatas.map((data) {
|
||||
final plan = data.vendorBenefitPlan;
|
||||
final total = plan.total?.toDouble() ?? 0.0;
|
||||
final current = data.current.toDouble();
|
||||
return Benefit(
|
||||
title: plan.title,
|
||||
entitlementHours: total,
|
||||
usedHours: total - current,
|
||||
entitlementHours: plan.total?.toDouble() ?? 0.0,
|
||||
usedHours: data.current.toDouble(),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Fallback for verification if DB is empty
|
||||
if (results.isEmpty) {
|
||||
return [
|
||||
const Benefit(title: 'Sick Days', entitlementHours: 40, usedHours: 30), // 10 remaining
|
||||
const Benefit(title: 'Vacation', entitlementHours: 40, usedHours: 0), // 40 remaining
|
||||
const Benefit(title: 'Holidays', entitlementHours: 24, usedHours: 0), // 24 remaining
|
||||
];
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,36 @@ class BenefitsOverviewPage extends StatelessWidget {
|
||||
appBar: _buildAppBar(context),
|
||||
body: BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == HomeStatus.loading ||
|
||||
state.status == HomeStatus.initial) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.status == HomeStatus.error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
child: Text(
|
||||
state.errorMessage ?? t.staff.home.benefits.overview.subtitle,
|
||||
style: UiTypography.body1r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final benefits = state.benefits;
|
||||
if (benefits.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
child: Text(
|
||||
t.staff.home.benefits.overview.subtitle,
|
||||
style: UiTypography.body1r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
@@ -32,7 +59,7 @@ class BenefitsOverviewPage extends StatelessWidget {
|
||||
left: UiConstants.space4,
|
||||
right: UiConstants.space4,
|
||||
top: UiConstants.space6,
|
||||
bottom: 120, // Extra padding for bottom navigation and safe area
|
||||
bottom: 120,
|
||||
),
|
||||
itemCount: benefits.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
||||
@@ -21,7 +21,9 @@ class OnboardingSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
|
||||
final TranslationsStaffProfileEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.profile;
|
||||
|
||||
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||
builder: (BuildContext context, ProfileState state) {
|
||||
@@ -49,6 +51,11 @@ class OnboardingSection extends StatelessWidget {
|
||||
completed: state.experienceComplete,
|
||||
onTap: () => Modular.to.toExperience(),
|
||||
),
|
||||
ProfileMenuItem(
|
||||
icon: UiIcons.shirt,
|
||||
label: i18n.menu_items.attire,
|
||||
onTap: () => Modular.to.toAttire(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart';
|
||||
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart';
|
||||
|
||||
import 'data/repositories_impl/attire_repository_impl.dart';
|
||||
import 'domain/repositories/attire_repository.dart';
|
||||
import 'domain/usecases/get_attire_options_usecase.dart';
|
||||
import 'domain/usecases/save_attire_usecase.dart';
|
||||
import 'domain/usecases/upload_attire_photo_usecase.dart';
|
||||
import 'presentation/blocs/attire_cubit.dart';
|
||||
import 'presentation/pages/attire_page.dart';
|
||||
|
||||
class StaffAttireModule extends Module {
|
||||
@@ -19,9 +20,10 @@ class StaffAttireModule extends Module {
|
||||
i.addLazySingleton(GetAttireOptionsUseCase.new);
|
||||
i.addLazySingleton(SaveAttireUseCase.new);
|
||||
i.addLazySingleton(UploadAttirePhotoUseCase.new);
|
||||
|
||||
|
||||
// BLoC
|
||||
i.addLazySingleton(AttireCubit.new);
|
||||
i.add(AttireCaptureCubit.new);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
@@ -6,30 +5,19 @@ import '../../domain/repositories/attire_repository.dart';
|
||||
|
||||
/// Implementation of [AttireRepository].
|
||||
///
|
||||
/// Delegates data access to [DataConnectService].
|
||||
/// Delegates data access to [StaffConnectorRepository].
|
||||
class AttireRepositoryImpl implements AttireRepository {
|
||||
|
||||
/// Creates an [AttireRepositoryImpl].
|
||||
AttireRepositoryImpl({DataConnectService? service})
|
||||
: _service = service ?? DataConnectService.instance;
|
||||
/// The Data Connect service.
|
||||
final DataConnectService _service;
|
||||
AttireRepositoryImpl({StaffConnectorRepository? connector})
|
||||
: _connector =
|
||||
connector ?? DataConnectService.instance.getStaffRepository();
|
||||
|
||||
/// The Staff Connector repository.
|
||||
final StaffConnectorRepository _connector;
|
||||
|
||||
@override
|
||||
Future<List<AttireItem>> getAttireOptions() async {
|
||||
return _service.run(() async {
|
||||
final QueryResult<ListAttireOptionsData, void> result =
|
||||
await _service.connector.listAttireOptions().execute();
|
||||
return result.data.attireOptions
|
||||
.map((ListAttireOptionsAttireOptions e) => AttireItem(
|
||||
id: e.itemId,
|
||||
label: e.label,
|
||||
iconName: e.icon,
|
||||
imageUrl: e.imageUrl,
|
||||
isMandatory: e.isMandatory ?? false,
|
||||
))
|
||||
.toList();
|
||||
});
|
||||
return _connector.getAttireOptions();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -37,16 +25,22 @@ class AttireRepositoryImpl implements AttireRepository {
|
||||
required List<String> selectedItemIds,
|
||||
required Map<String, String> photoUrls,
|
||||
}) async {
|
||||
// TODO: Connect to actual backend mutation when available.
|
||||
// For now, simulate network delay as per prototype behavior.
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
// We already upsert photos in uploadPhoto (to follow the new flow).
|
||||
// This could save selections if there was a separate "SelectedAttire" table.
|
||||
// For now, it's a no-op as the source of truth is the StaffAttire table.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> uploadPhoto(String itemId) async {
|
||||
// TODO: Connect to actual storage service/mutation when available.
|
||||
// For now, simulate upload delay and return mock URL.
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
return 'mock_url_for_$itemId';
|
||||
// In a real app, this would upload to Firebase Storage first.
|
||||
// Since the prototype returns a mock URL, we'll use that to upsert our record.
|
||||
final String mockUrl = 'mock_url_for_$itemId';
|
||||
|
||||
await _connector.upsertStaffAttire(
|
||||
attireOptionId: itemId,
|
||||
photoUrl: mockUrl,
|
||||
);
|
||||
|
||||
return mockUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_attire/src/domain/arguments/save_attire_arguments.dart';
|
||||
import 'package:staff_attire/src/domain/usecases/get_attire_options_usecase.dart';
|
||||
import 'package:staff_attire/src/domain/usecases/save_attire_usecase.dart';
|
||||
|
||||
import 'attire_state.dart';
|
||||
|
||||
class AttireCubit extends Cubit<AttireState>
|
||||
with BlocErrorHandler<AttireState> {
|
||||
AttireCubit(this._getAttireOptionsUseCase, this._saveAttireUseCase)
|
||||
: super(const AttireState()) {
|
||||
loadOptions();
|
||||
}
|
||||
final GetAttireOptionsUseCase _getAttireOptionsUseCase;
|
||||
final SaveAttireUseCase _saveAttireUseCase;
|
||||
|
||||
Future<void> loadOptions() async {
|
||||
emit(state.copyWith(status: AttireStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final List<AttireItem> options = await _getAttireOptionsUseCase();
|
||||
|
||||
// Extract photo URLs and selection status from backend data
|
||||
final Map<String, String> photoUrls = <String, String>{};
|
||||
final List<String> selectedIds = <String>[];
|
||||
|
||||
for (final AttireItem item in options) {
|
||||
if (item.photoUrl != null) {
|
||||
photoUrls[item.id] = item.photoUrl!;
|
||||
}
|
||||
// If mandatory or has photo, consider it selected initially
|
||||
if (item.isMandatory || item.photoUrl != null) {
|
||||
selectedIds.add(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AttireStatus.success,
|
||||
options: options,
|
||||
selectedIds: selectedIds,
|
||||
photoUrls: photoUrls,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) =>
|
||||
state.copyWith(status: AttireStatus.failure, errorMessage: errorKey),
|
||||
);
|
||||
}
|
||||
|
||||
void toggleSelection(String id) {
|
||||
// Prevent unselecting mandatory items
|
||||
if (state.isMandatory(id)) return;
|
||||
|
||||
final List<String> currentSelection = List<String>.from(state.selectedIds);
|
||||
if (currentSelection.contains(id)) {
|
||||
currentSelection.remove(id);
|
||||
} else {
|
||||
currentSelection.add(id);
|
||||
}
|
||||
emit(state.copyWith(selectedIds: currentSelection));
|
||||
}
|
||||
|
||||
void syncCapturedPhoto(String itemId, String url) {
|
||||
// When a photo is captured, we refresh the options to get the updated status from backend
|
||||
loadOptions();
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
if (!state.canSave) return;
|
||||
|
||||
emit(state.copyWith(status: AttireStatus.saving));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
await _saveAttireUseCase(
|
||||
SaveAttireArguments(
|
||||
selectedItemIds: state.selectedIds,
|
||||
photoUrls: state.photoUrls,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(status: AttireStatus.saved));
|
||||
},
|
||||
onError: (String errorKey) =>
|
||||
state.copyWith(status: AttireStatus.failure, errorMessage: errorKey),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,51 +4,51 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
enum AttireStatus { initial, loading, success, failure, saving, saved }
|
||||
|
||||
class AttireState extends Equatable {
|
||||
|
||||
const AttireState({
|
||||
this.status = AttireStatus.initial,
|
||||
this.options = const <AttireItem>[],
|
||||
this.selectedIds = const <String>[],
|
||||
this.photoUrls = const <String, String>{},
|
||||
this.uploadingStatus = const <String, bool>{},
|
||||
this.attestationChecked = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
final AttireStatus status;
|
||||
final List<AttireItem> options;
|
||||
final List<String> selectedIds;
|
||||
final Map<String, String> photoUrls;
|
||||
final Map<String, bool> uploadingStatus;
|
||||
final bool attestationChecked;
|
||||
final String? errorMessage;
|
||||
|
||||
bool get uploading => uploadingStatus.values.any((bool u) => u);
|
||||
|
||||
/// Helper to check if item is mandatory
|
||||
bool isMandatory(String id) {
|
||||
return options.firstWhere((AttireItem e) => e.id == id, orElse: () => const AttireItem(id: '', label: '')).isMandatory;
|
||||
return options
|
||||
.firstWhere(
|
||||
(AttireItem e) => e.id == id,
|
||||
orElse: () => const AttireItem(id: '', label: ''),
|
||||
)
|
||||
.isMandatory;
|
||||
}
|
||||
|
||||
/// Validation logic
|
||||
bool get allMandatorySelected {
|
||||
final Iterable<String> mandatoryIds = options.where((AttireItem e) => e.isMandatory).map((AttireItem e) => e.id);
|
||||
final Iterable<String> mandatoryIds = options
|
||||
.where((AttireItem e) => e.isMandatory)
|
||||
.map((AttireItem e) => e.id);
|
||||
return mandatoryIds.every((String id) => selectedIds.contains(id));
|
||||
}
|
||||
|
||||
bool get allMandatoryHavePhotos {
|
||||
final Iterable<String> mandatoryIds = options.where((AttireItem e) => e.isMandatory).map((AttireItem e) => e.id);
|
||||
final Iterable<String> mandatoryIds = options
|
||||
.where((AttireItem e) => e.isMandatory)
|
||||
.map((AttireItem e) => e.id);
|
||||
return mandatoryIds.every((String id) => photoUrls.containsKey(id));
|
||||
}
|
||||
|
||||
bool get canSave => allMandatorySelected && allMandatoryHavePhotos && attestationChecked && !uploading;
|
||||
bool get canSave => allMandatorySelected && allMandatoryHavePhotos;
|
||||
|
||||
AttireState copyWith({
|
||||
AttireStatus? status,
|
||||
List<AttireItem>? options,
|
||||
List<String>? selectedIds,
|
||||
Map<String, String>? photoUrls,
|
||||
Map<String, bool>? uploadingStatus,
|
||||
bool? attestationChecked,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return AttireState(
|
||||
@@ -56,20 +56,16 @@ class AttireState extends Equatable {
|
||||
options: options ?? this.options,
|
||||
selectedIds: selectedIds ?? this.selectedIds,
|
||||
photoUrls: photoUrls ?? this.photoUrls,
|
||||
uploadingStatus: uploadingStatus ?? this.uploadingStatus,
|
||||
attestationChecked: attestationChecked ?? this.attestationChecked,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
options,
|
||||
selectedIds,
|
||||
photoUrls,
|
||||
uploadingStatus,
|
||||
attestationChecked,
|
||||
errorMessage
|
||||
];
|
||||
status,
|
||||
options,
|
||||
selectedIds,
|
||||
photoUrls,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:staff_attire/src/domain/arguments/upload_attire_photo_arguments.dart';
|
||||
import 'package:staff_attire/src/domain/usecases/upload_attire_photo_usecase.dart';
|
||||
|
||||
import 'attire_capture_state.dart';
|
||||
|
||||
class AttireCaptureCubit extends Cubit<AttireCaptureState>
|
||||
with BlocErrorHandler<AttireCaptureState> {
|
||||
AttireCaptureCubit(this._uploadAttirePhotoUseCase)
|
||||
: super(const AttireCaptureState());
|
||||
|
||||
final UploadAttirePhotoUseCase _uploadAttirePhotoUseCase;
|
||||
|
||||
void toggleAttestation(bool value) {
|
||||
emit(state.copyWith(isAttested: value));
|
||||
}
|
||||
|
||||
Future<void> uploadPhoto(String itemId) async {
|
||||
emit(state.copyWith(status: AttireCaptureStatus.uploading));
|
||||
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final String url = await _uploadAttirePhotoUseCase(
|
||||
UploadAttirePhotoArguments(itemId: itemId),
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(status: AttireCaptureStatus.success, photoUrl: url),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: AttireCaptureStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum AttireCaptureStatus { initial, uploading, success, failure }
|
||||
|
||||
class AttireCaptureState extends Equatable {
|
||||
const AttireCaptureState({
|
||||
this.status = AttireCaptureStatus.initial,
|
||||
this.isAttested = false,
|
||||
this.photoUrl,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final AttireCaptureStatus status;
|
||||
final bool isAttested;
|
||||
final String? photoUrl;
|
||||
final String? errorMessage;
|
||||
|
||||
AttireCaptureState copyWith({
|
||||
AttireCaptureStatus? status,
|
||||
bool? isAttested,
|
||||
String? photoUrl,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return AttireCaptureState(
|
||||
status: status ?? this.status,
|
||||
isAttested: isAttested ?? this.isAttested,
|
||||
photoUrl: photoUrl ?? this.photoUrl,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
isAttested,
|
||||
photoUrl,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/arguments/save_attire_arguments.dart';
|
||||
import '../../domain/arguments/upload_attire_photo_arguments.dart';
|
||||
import '../../domain/usecases/get_attire_options_usecase.dart';
|
||||
import '../../domain/usecases/save_attire_usecase.dart';
|
||||
import '../../domain/usecases/upload_attire_photo_usecase.dart';
|
||||
import 'attire_state.dart';
|
||||
|
||||
class AttireCubit extends Cubit<AttireState>
|
||||
with BlocErrorHandler<AttireState> {
|
||||
|
||||
AttireCubit(
|
||||
this._getAttireOptionsUseCase,
|
||||
this._saveAttireUseCase,
|
||||
this._uploadAttirePhotoUseCase,
|
||||
) : super(const AttireState()) {
|
||||
loadOptions();
|
||||
}
|
||||
final GetAttireOptionsUseCase _getAttireOptionsUseCase;
|
||||
final SaveAttireUseCase _saveAttireUseCase;
|
||||
final UploadAttirePhotoUseCase _uploadAttirePhotoUseCase;
|
||||
|
||||
Future<void> loadOptions() async {
|
||||
emit(state.copyWith(status: AttireStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final List<AttireItem> options = await _getAttireOptionsUseCase();
|
||||
|
||||
// Auto-select mandatory items initially as per prototype
|
||||
final List<String> mandatoryIds =
|
||||
options
|
||||
.where((AttireItem e) => e.isMandatory)
|
||||
.map((AttireItem e) => e.id)
|
||||
.toList();
|
||||
|
||||
final List<String> initialSelection = List<String>.from(
|
||||
state.selectedIds,
|
||||
);
|
||||
for (final String id in mandatoryIds) {
|
||||
if (!initialSelection.contains(id)) {
|
||||
initialSelection.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AttireStatus.success,
|
||||
options: options,
|
||||
selectedIds: initialSelection,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError:
|
||||
(String errorKey) => state.copyWith(
|
||||
status: AttireStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void toggleSelection(String id) {
|
||||
// Prevent unselecting mandatory items
|
||||
if (state.isMandatory(id)) return;
|
||||
|
||||
final List<String> currentSelection = List<String>.from(state.selectedIds);
|
||||
if (currentSelection.contains(id)) {
|
||||
currentSelection.remove(id);
|
||||
} else {
|
||||
currentSelection.add(id);
|
||||
}
|
||||
emit(state.copyWith(selectedIds: currentSelection));
|
||||
}
|
||||
|
||||
void toggleAttestation(bool value) {
|
||||
emit(state.copyWith(attestationChecked: value));
|
||||
}
|
||||
|
||||
Future<void> uploadPhoto(String itemId) async {
|
||||
final Map<String, bool> currentUploading = Map<String, bool>.from(
|
||||
state.uploadingStatus,
|
||||
);
|
||||
currentUploading[itemId] = true;
|
||||
emit(state.copyWith(uploadingStatus: currentUploading));
|
||||
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final String url = await _uploadAttirePhotoUseCase(
|
||||
UploadAttirePhotoArguments(itemId: itemId),
|
||||
);
|
||||
|
||||
final Map<String, String> currentPhotos = Map<String, String>.from(
|
||||
state.photoUrls,
|
||||
);
|
||||
currentPhotos[itemId] = url;
|
||||
|
||||
// Auto-select item on upload success if not selected
|
||||
final List<String> currentSelection = List<String>.from(
|
||||
state.selectedIds,
|
||||
);
|
||||
if (!currentSelection.contains(itemId)) {
|
||||
currentSelection.add(itemId);
|
||||
}
|
||||
|
||||
final Map<String, bool> updatedUploading = Map<String, bool>.from(
|
||||
state.uploadingStatus,
|
||||
);
|
||||
updatedUploading[itemId] = false;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
uploadingStatus: updatedUploading,
|
||||
photoUrls: currentPhotos,
|
||||
selectedIds: currentSelection,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) {
|
||||
final Map<String, bool> updatedUploading = Map<String, bool>.from(
|
||||
state.uploadingStatus,
|
||||
);
|
||||
updatedUploading[itemId] = false;
|
||||
// Could handle error specifically via snackbar event
|
||||
// For now, attaching the error message but keeping state generally usable
|
||||
return state.copyWith(
|
||||
uploadingStatus: updatedUploading,
|
||||
errorMessage: errorKey,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
if (!state.canSave) return;
|
||||
|
||||
emit(state.copyWith(status: AttireStatus.saving));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
await _saveAttireUseCase(
|
||||
SaveAttireArguments(
|
||||
selectedItemIds: state.selectedIds,
|
||||
photoUrls: state.photoUrls,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(status: AttireStatus.saved));
|
||||
},
|
||||
onError:
|
||||
(String errorKey) => state.copyWith(
|
||||
status: AttireStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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_domain/krow_domain.dart';
|
||||
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart';
|
||||
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart';
|
||||
|
||||
import '../widgets/attestation_checkbox.dart';
|
||||
import '../widgets/attire_capture_page/attire_image_preview.dart';
|
||||
import '../widgets/attire_capture_page/attire_upload_buttons.dart';
|
||||
import '../widgets/attire_capture_page/attire_verification_status_card.dart';
|
||||
|
||||
class AttireCapturePage extends StatefulWidget {
|
||||
const AttireCapturePage({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.initialPhotoUrl,
|
||||
});
|
||||
|
||||
final AttireItem item;
|
||||
final String? initialPhotoUrl;
|
||||
|
||||
@override
|
||||
State<AttireCapturePage> createState() => _AttireCapturePageState();
|
||||
}
|
||||
|
||||
class _AttireCapturePageState extends State<AttireCapturePage> {
|
||||
void _onUpload(BuildContext context) {
|
||||
final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>(
|
||||
context,
|
||||
);
|
||||
if (!cubit.state.isAttested) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: 'Please attest that you own this item.',
|
||||
type: UiSnackbarType.error,
|
||||
margin: const EdgeInsets.all(UiConstants.space4),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Call the upload via cubit
|
||||
cubit.uploadPhoto(widget.item.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AttireCaptureCubit>(
|
||||
create: (_) => Modular.get<AttireCaptureCubit>(),
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>(
|
||||
context,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(title: widget.item.label, showBackButton: true),
|
||||
body: BlocConsumer<AttireCaptureCubit, AttireCaptureState>(
|
||||
bloc: cubit,
|
||||
listener: (BuildContext context, AttireCaptureState state) {
|
||||
if (state.status == AttireCaptureStatus.failure) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: translateErrorKey(state.errorMessage ?? 'Error'),
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, AttireCaptureState state) {
|
||||
final bool isUploading =
|
||||
state.status == AttireCaptureStatus.uploading;
|
||||
final String? currentPhotoUrl =
|
||||
state.photoUrl ?? widget.initialPhotoUrl;
|
||||
final bool hasUploadedPhoto = currentPhotoUrl != null;
|
||||
|
||||
final String statusText = switch (widget
|
||||
.item
|
||||
.verificationStatus) {
|
||||
AttireVerificationStatus.success => 'Approved',
|
||||
AttireVerificationStatus.failed => 'Rejected',
|
||||
AttireVerificationStatus.pending => 'Pending Verification',
|
||||
_ =>
|
||||
hasUploadedPhoto ? 'Pending Verification' : 'Not Uploaded',
|
||||
};
|
||||
|
||||
final Color statusColor =
|
||||
switch (widget.item.verificationStatus) {
|
||||
AttireVerificationStatus.success => UiColors.textSuccess,
|
||||
AttireVerificationStatus.failed => UiColors.textError,
|
||||
AttireVerificationStatus.pending => UiColors.textWarning,
|
||||
_ =>
|
||||
hasUploadedPhoto
|
||||
? UiColors.textWarning
|
||||
: UiColors.textInactive,
|
||||
};
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
// Image Preview (Toggle between example and uploaded)
|
||||
if (hasUploadedPhoto) ...<Widget>[
|
||||
Text(
|
||||
'Your Uploaded Photo',
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
AttireImagePreview(imageUrl: currentPhotoUrl),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
'Reference Example',
|
||||
style: UiTypography.body2b.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
child: Image.network(
|
||||
widget.item.imageUrl ?? '',
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...<Widget>[
|
||||
AttireImagePreview(
|
||||
imageUrl: widget.item.imageUrl,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
'Example of the item that you need to upload.',
|
||||
style: UiTypography.body1b.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
if (widget.item.description != null)
|
||||
Text(
|
||||
widget.item.description!,
|
||||
style: UiTypography.body1r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Verification info
|
||||
AttireVerificationStatusCard(
|
||||
statusText: statusText,
|
||||
statusColor: statusColor,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
AttestationCheckbox(
|
||||
isChecked: state.isAttested,
|
||||
onChanged: (bool? val) {
|
||||
cubit.toggleAttestation(val ?? false);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
if (isUploading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(UiConstants.space8),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else
|
||||
AttireUploadButtons(onUpload: _onUpload),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasUploadedPhoto)
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: 'Submit Image',
|
||||
onPressed: () {
|
||||
Modular.to.pop(currentPhotoUrl);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +1,143 @@
|
||||
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:core_localization/core_localization.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart';
|
||||
import 'package:staff_attire/src/presentation/blocs/attire/attire_state.dart';
|
||||
|
||||
import '../blocs/attire_cubit.dart';
|
||||
import '../blocs/attire_state.dart';
|
||||
import '../widgets/attestation_checkbox.dart';
|
||||
import '../widgets/attire_bottom_bar.dart';
|
||||
import '../widgets/attire_grid.dart';
|
||||
import '../widgets/attire_filter_chips.dart';
|
||||
import '../widgets/attire_info_card.dart';
|
||||
import '../widgets/attire_item_card.dart';
|
||||
import 'attire_capture_page.dart';
|
||||
|
||||
class AttirePage extends StatelessWidget {
|
||||
class AttirePage extends StatefulWidget {
|
||||
const AttirePage({super.key});
|
||||
|
||||
@override
|
||||
State<AttirePage> createState() => _AttirePageState();
|
||||
}
|
||||
|
||||
class _AttirePageState extends State<AttirePage> {
|
||||
String _filter = 'All';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Note: t.staff_profile_attire is available via re-export of core_localization
|
||||
final AttireCubit cubit = Modular.get<AttireCubit>();
|
||||
|
||||
return BlocProvider<AttireCubit>.value(
|
||||
value: cubit,
|
||||
child: Scaffold(
|
||||
backgroundColor: UiColors.background, // FAFBFC
|
||||
appBar: AppBar(
|
||||
backgroundColor: UiColors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconSecondary),
|
||||
onPressed: () => Modular.to.pop(),
|
||||
),
|
||||
title: Text(
|
||||
t.staff_profile_attire.title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1.0),
|
||||
child: Container(color: UiColors.border, height: 1.0),
|
||||
),
|
||||
),
|
||||
body: BlocConsumer<AttireCubit, AttireState>(
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: t.staff_profile_attire.title,
|
||||
showBackButton: true,
|
||||
),
|
||||
body: BlocProvider<AttireCubit>.value(
|
||||
value: cubit,
|
||||
child: BlocConsumer<AttireCubit, AttireState>(
|
||||
listener: (BuildContext context, AttireState state) {
|
||||
if (state.status == AttireStatus.failure) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: translateErrorKey(state.errorMessage ?? 'Error'),
|
||||
type: UiSnackbarType.error,
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 150,
|
||||
left: UiConstants.space4,
|
||||
right: UiConstants.space4,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.status == AttireStatus.saved) {
|
||||
Modular.to.pop();
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, AttireState state) {
|
||||
if (state.status == AttireStatus.loading && state.options.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final List<AttireItem> options = state.options;
|
||||
final List<AttireItem> filteredOptions = options.where((
|
||||
AttireItem item,
|
||||
) {
|
||||
if (_filter == 'Required') return item.isMandatory;
|
||||
if (_filter == 'Non-Essential') return !item.isMandatory;
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const AttireInfoCard(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
AttireGrid(
|
||||
items: state.options,
|
||||
selectedIds: state.selectedIds,
|
||||
photoUrls: state.photoUrls,
|
||||
uploadingStatus: state.uploadingStatus,
|
||||
onToggle: cubit.toggleSelection,
|
||||
onUpload: cubit.uploadPhoto,
|
||||
|
||||
// Filter Chips
|
||||
AttireFilterChips(
|
||||
selectedFilter: _filter,
|
||||
onFilterChanged: (String value) {
|
||||
setState(() {
|
||||
_filter = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
AttestationCheckbox(
|
||||
isChecked: state.attestationChecked,
|
||||
onChanged: (bool? val) => cubit.toggleAttestation(val ?? false),
|
||||
),
|
||||
|
||||
// Item List
|
||||
if (filteredOptions.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space10,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.shirt,
|
||||
size: 48,
|
||||
color: UiColors.iconInactive,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
'No items found for this filter.',
|
||||
style: UiTypography.body1m.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...filteredOptions.map((AttireItem item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space3,
|
||||
),
|
||||
child: AttireItemCard(
|
||||
item: item,
|
||||
isUploading: false,
|
||||
uploadedPhotoUrl: state.photoUrls[item.id],
|
||||
onTap: () async {
|
||||
final String? resultUrl =
|
||||
await Navigator.push<String?>(
|
||||
context,
|
||||
MaterialPageRoute<String?>(
|
||||
builder: (BuildContext ctx) =>
|
||||
AttireCapturePage(
|
||||
item: item,
|
||||
initialPhotoUrl:
|
||||
state.photoUrls[item.id],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (resultUrl != null && mounted) {
|
||||
cubit.syncCapturedPhoto(item.id, resultUrl);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: UiConstants.space20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AttireBottomBar(
|
||||
canSave: state.canSave,
|
||||
allMandatorySelected: state.allMandatorySelected,
|
||||
allMandatoryHavePhotos: state.allMandatoryHavePhotos,
|
||||
attestationChecked: state.attestationChecked,
|
||||
onSave: cubit.save,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AttireImagePreview extends StatelessWidget {
|
||||
const AttireImagePreview({super.key, required this.imageUrl});
|
||||
|
||||
final String? imageUrl;
|
||||
|
||||
void _viewEnlargedImage(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 500, maxWidth: 500),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
imageUrl ??
|
||||
'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400',
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => _viewEnlargedImage(context),
|
||||
child: Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
boxShadow: const <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: Color(0x19000000),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
imageUrl ??
|
||||
'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400',
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: const Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
UiIcons.search,
|
||||
color: UiColors.white,
|
||||
shadows: <Shadow>[Shadow(color: Colors.black, blurRadius: 4)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AttireUploadButtons extends StatelessWidget {
|
||||
const AttireUploadButtons({super.key, required this.onUpload});
|
||||
|
||||
final void Function(BuildContext) onUpload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
leadingIcon: UiIcons.gallery,
|
||||
text: 'Gallery',
|
||||
onPressed: () => onUpload(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
leadingIcon: UiIcons.camera,
|
||||
text: 'Camera',
|
||||
onPressed: () => onUpload(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AttireVerificationStatusCard extends StatelessWidget {
|
||||
const AttireVerificationStatusCard({
|
||||
super.key,
|
||||
required this.statusText,
|
||||
required this.statusColor,
|
||||
});
|
||||
|
||||
final String statusText;
|
||||
final Color statusColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgPopup,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.info, color: UiColors.primary, size: 24),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Verification Status',
|
||||
style: UiTypography.footnote2m.textPrimary,
|
||||
),
|
||||
Text(
|
||||
statusText,
|
||||
style: UiTypography.body2m.copyWith(color: statusColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AttireFilterChips extends StatelessWidget {
|
||||
const AttireFilterChips({
|
||||
super.key,
|
||||
required this.selectedFilter,
|
||||
required this.onFilterChanged,
|
||||
});
|
||||
|
||||
final String selectedFilter;
|
||||
final ValueChanged<String> onFilterChanged;
|
||||
|
||||
Widget _buildFilterChip(String label) {
|
||||
final bool isSelected = selectedFilter == label;
|
||||
return GestureDetector(
|
||||
onTap: () => onFilterChanged(label),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary : UiColors.white,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
border: Border.all(
|
||||
color: isSelected ? UiColors.primary : UiColors.border,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: (isSelected
|
||||
? UiTypography.footnote2m.white
|
||||
: UiTypography.footnote2m.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
_buildFilterChip('All'),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_buildFilterChip('Required'),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_buildFilterChip('Non-Essential'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class AttireGrid extends StatelessWidget {
|
||||
|
||||
const AttireGrid({
|
||||
super.key,
|
||||
required this.items,
|
||||
@@ -53,7 +52,9 @@ class AttireGrid extends StatelessWidget {
|
||||
) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary.withOpacity(0.1) : Colors.transparent,
|
||||
color: isSelected
|
||||
? UiColors.primary.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
border: Border.all(
|
||||
color: isSelected ? UiColors.primary : UiColors.border,
|
||||
@@ -67,19 +68,17 @@ class AttireGrid extends StatelessWidget {
|
||||
top: UiConstants.space2,
|
||||
left: UiConstants.space2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.destructive, // Red
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
child: Text(
|
||||
t.staff_profile_attire.status.required,
|
||||
style: UiTypography.body3m.copyWith( // 12px Medium -> Bold
|
||||
style: UiTypography.body3m.copyWith(
|
||||
// 12px Medium -> Bold
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 9,
|
||||
fontSize: 9,
|
||||
color: UiColors.white,
|
||||
),
|
||||
),
|
||||
@@ -97,11 +96,7 @@ class AttireGrid extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.check,
|
||||
color: UiColors.white,
|
||||
size: 12,
|
||||
),
|
||||
child: Icon(UiIcons.check, color: UiColors.white, size: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -119,26 +114,34 @@ class AttireGrid extends StatelessWidget {
|
||||
height: 80,
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(item.imageUrl!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_getIcon(item.iconName),
|
||||
: const Icon(
|
||||
UiIcons.shirt,
|
||||
size: 48,
|
||||
color: UiColors.textPrimary, // Was charcoal
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
item.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
if (item.description != null)
|
||||
Text(
|
||||
item.description!,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -158,7 +161,9 @@ class AttireGrid extends StatelessWidget {
|
||||
border: Border.all(
|
||||
color: hasPhoto ? UiColors.primary : UiColors.border,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -169,7 +174,9 @@ class AttireGrid extends StatelessWidget {
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(UiColors.primary),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
UiColors.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (hasPhoto)
|
||||
@@ -189,10 +196,12 @@ class AttireGrid extends StatelessWidget {
|
||||
isUploading
|
||||
? '...'
|
||||
: hasPhoto
|
||||
? t.staff_profile_attire.status.added
|
||||
: t.staff_profile_attire.status.add_photo,
|
||||
? t.staff_profile_attire.status.added
|
||||
: t.staff_profile_attire.status.add_photo,
|
||||
style: UiTypography.body3m.copyWith(
|
||||
color: hasPhoto ? UiColors.primary : UiColors.textSecondary,
|
||||
color: hasPhoto
|
||||
? UiColors.primary
|
||||
: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -217,23 +226,4 @@ class AttireGrid extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getIcon(String? name) {
|
||||
switch (name) {
|
||||
case 'footprints':
|
||||
return UiIcons.footprints;
|
||||
case 'scissors':
|
||||
return UiIcons.scissors;
|
||||
case 'user':
|
||||
return UiIcons.user;
|
||||
case 'shirt':
|
||||
return UiIcons.shirt;
|
||||
case 'hardHat':
|
||||
return UiIcons.hardHat;
|
||||
case 'chefHat':
|
||||
return UiIcons.chefHat;
|
||||
default:
|
||||
return UiIcons.help;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class AttireItemCard extends StatelessWidget {
|
||||
final AttireItem item;
|
||||
final String? uploadedPhotoUrl;
|
||||
final bool isUploading;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const AttireItemCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.uploadedPhotoUrl,
|
||||
this.isUploading = false,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasPhoto = item.photoUrl != null;
|
||||
final String statusText = switch (item.verificationStatus) {
|
||||
AttireVerificationStatus.success => 'Approved',
|
||||
AttireVerificationStatus.failed => 'Rejected',
|
||||
AttireVerificationStatus.pending => 'Pending',
|
||||
_ => hasPhoto ? 'Pending' : 'To Do',
|
||||
};
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Image
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.background,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
item.imageUrl ??
|
||||
'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400',
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
// details
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(item.label, style: UiTypography.body1m.textPrimary),
|
||||
if (item.description != null) ...<Widget>[
|
||||
Text(
|
||||
item.description!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
if (item.isMandatory)
|
||||
const UiChip(
|
||||
label: 'Required',
|
||||
size: UiChipSize.xSmall,
|
||||
variant: UiChipVariant.destructive,
|
||||
),
|
||||
if (isUploading)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else if (hasPhoto)
|
||||
UiChip(
|
||||
label: statusText,
|
||||
size: UiChipSize.xSmall,
|
||||
variant: item.verificationStatus == 'SUCCESS'
|
||||
? UiChipVariant.primary
|
||||
: UiChipVariant.secondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
// Chevron or status
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: 20),
|
||||
if (!hasPhoto && !isUploading)
|
||||
const Icon(
|
||||
UiIcons.chevronRight,
|
||||
color: UiColors.textInactive,
|
||||
size: 24,
|
||||
)
|
||||
else if (hasPhoto && !isUploading)
|
||||
Icon(
|
||||
item.verificationStatus == 'SUCCESS'
|
||||
? UiIcons.check
|
||||
: UiIcons.clock,
|
||||
color: item.verificationStatus == 'SUCCESS'
|
||||
? UiColors.textPrimary
|
||||
: UiColors.textWarning,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user