Merge outstanding changes before pulling dev

This commit is contained in:
2026-02-25 19:11:04 +05:30
74 changed files with 2802 additions and 1462 deletions

View File

@@ -1,4 +1,5 @@
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import 'route_paths.dart'; import 'route_paths.dart';
@@ -145,6 +146,22 @@ extension ClientNavigator on IModularNavigator {
await pushNamed(ClientPaths.hubs); 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 // ORDER CREATION
// ========================================================================== // ==========================================================================

View File

@@ -16,7 +16,7 @@ class ClientPaths {
/// Generate child route based on the given route and parent route /// Generate child route based on the given route and parent route
/// ///
/// This is useful for creating nested routes within modules. /// 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, ''); final String childPath = child.replaceFirst(parent, '');
// check if the child path is empty // check if the child path is empty
@@ -82,10 +82,12 @@ class ClientPaths {
static const String billing = '/client-main/billing'; static const String billing = '/client-main/billing';
/// Completion review page - review shift completion records. /// 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. /// 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. /// Invoice ready page - view status of approved invoices.
static const String invoiceReady = '/client-main/billing/invoice-ready'; 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. /// View and manage physical locations/hubs where staff are deployed.
static const String hubs = '/client-hubs'; 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 // ORDER CREATION & MANAGEMENT
// ========================================================================== // ==========================================================================

View File

@@ -1039,7 +1039,7 @@
} }
}, },
"staff_profile_attire": { "staff_profile_attire": {
"title": "Attire", "title": "Verify Attire",
"info_card": { "info_card": {
"title": "Your Wardrobe", "title": "Your Wardrobe",
"description": "Select the attire items you own. This helps us match you with shifts that fit your wardrobe." "description": "Select the attire items you own. This helps us match you with shifts that fit your wardrobe."

View File

@@ -1039,7 +1039,7 @@
} }
}, },
"staff_profile_attire": { "staff_profile_attire": {
"title": "Vestimenta", "title": "Verificar Vestimenta",
"info_card": { "info_card": {
"title": "Tu Vestuario", "title": "Tu Vestuario",
"description": "Selecciona los art\u00edculos de vestimenta que posees. Esto nos ayuda a asignarte turnos que se ajusten a tu vestuario." "description": "Selecciona los art\u00edculos de vestimenta que posees. Esto nos ayuda a asignarte turnos que se ajusten a tu vestuario."

View File

@@ -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 // 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: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'; import 'package:krow_domain/krow_domain.dart';
/// Implementation of [StaffConnectorRepository]. /// Implementation of [StaffConnectorRepository].
@@ -11,9 +12,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
/// Creates a new [StaffConnectorRepositoryImpl]. /// Creates a new [StaffConnectorRepositoryImpl].
/// ///
/// Requires a [DataConnectService] instance for backend communication. /// Requires a [DataConnectService] instance for backend communication.
StaffConnectorRepositoryImpl({ StaffConnectorRepositoryImpl({DataConnectService? service})
DataConnectService? service, : _service = service ?? DataConnectService.instance;
}) : _service = service ?? DataConnectService.instance;
final DataConnectService _service; final DataConnectService _service;
@@ -22,15 +22,17 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async { return _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
final QueryResult<GetStaffProfileCompletionData, final QueryResult<
GetStaffProfileCompletionVariables> response = GetStaffProfileCompletionData,
await _service.connector GetStaffProfileCompletionVariables
.getStaffProfileCompletion(id: staffId) >
.execute(); response = await _service.connector
.getStaffProfileCompletion(id: staffId)
.execute();
final GetStaffProfileCompletionStaff? staff = response.data.staff; final GetStaffProfileCompletionStaff? staff = response.data.staff;
final List<GetStaffProfileCompletionEmergencyContacts> final List<GetStaffProfileCompletionEmergencyContacts> emergencyContacts =
emergencyContacts = response.data.emergencyContacts; response.data.emergencyContacts;
final List<GetStaffProfileCompletionTaxForms> taxForms = final List<GetStaffProfileCompletionTaxForms> taxForms =
response.data.taxForms; response.data.taxForms;
@@ -43,11 +45,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async { return _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
final QueryResult<GetStaffPersonalInfoCompletionData, final QueryResult<
GetStaffPersonalInfoCompletionVariables> response = GetStaffPersonalInfoCompletionData,
await _service.connector GetStaffPersonalInfoCompletionVariables
.getStaffPersonalInfoCompletion(id: staffId) >
.execute(); response = await _service.connector
.getStaffPersonalInfoCompletion(id: staffId)
.execute();
final GetStaffPersonalInfoCompletionStaff? staff = response.data.staff; final GetStaffPersonalInfoCompletionStaff? staff = response.data.staff;
@@ -60,11 +64,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async { return _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
final QueryResult<GetStaffEmergencyProfileCompletionData, final QueryResult<
GetStaffEmergencyProfileCompletionVariables> response = GetStaffEmergencyProfileCompletionData,
await _service.connector GetStaffEmergencyProfileCompletionVariables
.getStaffEmergencyProfileCompletion(id: staffId) >
.execute(); response = await _service.connector
.getStaffEmergencyProfileCompletion(id: staffId)
.execute();
return response.data.emergencyContacts.isNotEmpty; return response.data.emergencyContacts.isNotEmpty;
}); });
@@ -75,11 +81,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async { return _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
final QueryResult<GetStaffExperienceProfileCompletionData, final QueryResult<
GetStaffExperienceProfileCompletionVariables> response = GetStaffExperienceProfileCompletionData,
await _service.connector GetStaffExperienceProfileCompletionVariables
.getStaffExperienceProfileCompletion(id: staffId) >
.execute(); response = await _service.connector
.getStaffExperienceProfileCompletion(id: staffId)
.execute();
final GetStaffExperienceProfileCompletionStaff? staff = final GetStaffExperienceProfileCompletionStaff? staff =
response.data.staff; response.data.staff;
@@ -93,11 +101,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async { return _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
final QueryResult<GetStaffTaxFormsProfileCompletionData, final QueryResult<
GetStaffTaxFormsProfileCompletionVariables> response = GetStaffTaxFormsProfileCompletionData,
await _service.connector GetStaffTaxFormsProfileCompletionVariables
.getStaffTaxFormsProfileCompletion(id: staffId) >
.execute(); response = await _service.connector
.getStaffTaxFormsProfileCompletion(id: staffId)
.execute();
return response.data.taxForms.isNotEmpty; return response.data.taxForms.isNotEmpty;
}); });
@@ -135,9 +145,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
final bool hasExperience = final bool hasExperience =
(skills is List && skills.isNotEmpty) || (skills is List && skills.isNotEmpty) ||
(industries is List && industries.isNotEmpty); (industries is List && industries.isNotEmpty);
return emergencyContacts.isNotEmpty && return emergencyContacts.isNotEmpty && taxForms.isNotEmpty && hasExperience;
taxForms.isNotEmpty &&
hasExperience;
} }
@override @override
@@ -146,14 +154,10 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
final QueryResult<GetStaffByIdData, GetStaffByIdVariables> response = final QueryResult<GetStaffByIdData, GetStaffByIdVariables> response =
await _service.connector await _service.connector.getStaffById(id: staffId).execute();
.getStaffById(id: staffId)
.execute();
if (response.data.staff == null) { if (response.data.staff == null) {
throw const ServerException( throw const ServerException(technicalMessage: 'Staff not found');
technicalMessage: 'Staff not found',
);
} }
final GetStaffByIdStaff rawStaff = response.data.staff!; final GetStaffByIdStaff rawStaff = response.data.staff!;
@@ -183,11 +187,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return _service.run(() async { return _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
final QueryResult<ListBenefitsDataByStaffIdData, final QueryResult<
ListBenefitsDataByStaffIdVariables> response = ListBenefitsDataByStaffIdData,
await _service.connector ListBenefitsDataByStaffIdVariables
.listBenefitsDataByStaffId(staffId: staffId) >
.execute(); response = await _service.connector
.listBenefitsDataByStaffId(staffId: staffId)
.execute();
return response.data.benefitsDatas.map((data) { return response.data.benefitsDatas.map((data) {
final plan = data.vendorBenefitPlan; final plan = data.vendorBenefitPlan;
@@ -200,6 +206,68 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
}); });
} }
@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 @override
Future<void> signOut() async { Future<void> signOut() async {
try { try {
@@ -210,4 +278,3 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
} }
} }
} }

View File

@@ -45,6 +45,18 @@ abstract interface class StaffConnectorRepository {
/// Returns a list of [Benefit] entities. /// Returns a list of [Benefit] entities.
Future<List<Benefit>> getBenefits(); 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. /// Signs out the current user.
/// ///
/// Clears the user's session and authentication state. /// Clears the user's session and authentication state.

View File

@@ -276,4 +276,7 @@ class UiIcons {
/// Help circle icon for FAQs /// Help circle icon for FAQs
static const IconData helpCircle = _IconLib.helpCircle; static const IconData helpCircle = _IconLib.helpCircle;
/// Gallery icon for gallery
static const IconData gallery = _IconLib.galleryVertical;
} }

View File

@@ -374,7 +374,7 @@ class UiTypography {
/// Body 4 Regular - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826) /// Body 4 Regular - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826)
static final TextStyle body4r = _primaryBase.copyWith( static final TextStyle body4r = _primaryBase.copyWith(
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
fontSize: 12, fontSize: 10,
height: 1.5, height: 1.5,
letterSpacing: 0.05, letterSpacing: 0.05,
color: UiColors.textPrimary, color: UiColors.textPrimary,

View File

@@ -1,10 +1,6 @@
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:design_system/src/ui_typography.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../ui_icons.dart';
import 'ui_icon_button.dart';
/// A custom AppBar for the Krow UI design system. /// A custom AppBar for the Krow UI design system.
/// ///
/// This widget provides a consistent look and feel for top app bars across the application. /// 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({ const UiAppBar({
super.key, super.key,
this.title, this.title,
this.subtitle,
this.titleWidget, this.titleWidget,
this.leading, this.leading,
this.actions, this.actions,
@@ -25,6 +22,9 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget {
/// The title text to display in the app bar. /// The title text to display in the app bar.
final String? title; final String? title;
/// The subtitle text to display in the app bar.
final String? subtitle;
/// A widget to display instead of the title text. /// A widget to display instead of the title text.
final Widget? titleWidget; final Widget? titleWidget;
@@ -57,7 +57,19 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget {
return AppBar( return AppBar(
title: title:
titleWidget ?? 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:
leading ?? leading ??
(showBackButton (showBackButton

View File

@@ -5,6 +5,9 @@ import '../ui_typography.dart';
/// Sizes for the [UiChip] widget. /// Sizes for the [UiChip] widget.
enum UiChipSize { enum UiChipSize {
// X-Small size (e.g. for tags in tight spaces).
xSmall,
/// Small size (e.g. for tags in tight spaces). /// Small size (e.g. for tags in tight spaces).
small, small,
@@ -25,6 +28,9 @@ enum UiChipVariant {
/// Accent style with highlight background. /// Accent style with highlight background.
accent, accent,
/// Desructive style with red background.
destructive,
} }
/// A custom chip widget with supports for different sizes, themes, and icons. /// A custom chip widget with supports for different sizes, themes, and icons.
@@ -119,6 +125,8 @@ class UiChip extends StatelessWidget {
return UiColors.tagInProgress; return UiColors.tagInProgress;
case UiChipVariant.accent: case UiChipVariant.accent:
return UiColors.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; return UiColors.primary;
case UiChipVariant.accent: case UiChipVariant.accent:
return UiColors.accentForeground; return UiColors.accentForeground;
case UiChipVariant.destructive:
return UiColors.iconError;
} }
} }
TextStyle _getTextStyle() { TextStyle _getTextStyle() {
switch (size) { switch (size) {
case UiChipSize.xSmall:
return UiTypography.body4r;
case UiChipSize.small: case UiChipSize.small:
return UiTypography.body3r; return UiTypography.body3r;
case UiChipSize.medium: case UiChipSize.medium:
@@ -150,6 +162,8 @@ class UiChip extends StatelessWidget {
EdgeInsets _getPadding() { EdgeInsets _getPadding() {
switch (size) { switch (size) {
case UiChipSize.xSmall:
return const EdgeInsets.symmetric(horizontal: 6, vertical: 4);
case UiChipSize.small: case UiChipSize.small:
return const EdgeInsets.symmetric(horizontal: 10, vertical: 6); return const EdgeInsets.symmetric(horizontal: 10, vertical: 6);
case UiChipSize.medium: case UiChipSize.medium:
@@ -161,6 +175,8 @@ class UiChip extends StatelessWidget {
double _getIconSize() { double _getIconSize() {
switch (size) { switch (size) {
case UiChipSize.xSmall:
return 10;
case UiChipSize.small: case UiChipSize.small:
return 12; return 12;
case UiChipSize.medium: case UiChipSize.medium:
@@ -172,6 +188,8 @@ class UiChip extends StatelessWidget {
double _getGap() { double _getGap() {
switch (size) { switch (size) {
case UiChipSize.xSmall:
return UiConstants.space1;
case UiChipSize.small: case UiChipSize.small:
return UiConstants.space1; return UiConstants.space1;
case UiChipSize.medium: case UiChipSize.medium:

View File

@@ -68,6 +68,7 @@ export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
// Profile // Profile
export 'src/entities/profile/staff_document.dart'; export 'src/entities/profile/staff_document.dart';
export 'src/entities/profile/attire_item.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/relationship_type.dart';
export 'src/entities/profile/industry.dart'; export 'src/entities/profile/industry.dart';
export 'src/entities/profile/tax_form.dart'; export 'src/entities/profile/tax_form.dart';

View File

@@ -1,26 +1,31 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'attire_verification_status.dart';
/// Represents an attire item that a staff member might need or possess. /// Represents an attire item that a staff member might need or possess.
/// ///
/// Attire items are specific clothing or equipment required for jobs. /// Attire items are specific clothing or equipment required for jobs.
class AttireItem extends Equatable { class AttireItem extends Equatable {
/// Creates an [AttireItem]. /// Creates an [AttireItem].
const AttireItem({ const AttireItem({
required this.id, required this.id,
required this.label, required this.label,
this.iconName, this.description,
this.imageUrl, this.imageUrl,
this.isMandatory = false, this.isMandatory = false,
this.verificationStatus,
this.photoUrl,
this.verificationId,
}); });
/// Unique identifier of the attire item. /// Unique identifier of the attire item.
final String id; final String id;
/// Display name of the item. /// Display name of the item.
final String label; final String label;
/// Name of the icon to display (mapped in UI). /// Optional description for the attire item.
final String? iconName; final String? description;
/// URL of the reference image. /// URL of the reference image.
final String? imageUrl; final String? imageUrl;
@@ -28,6 +33,24 @@ class AttireItem extends Equatable {
/// Whether this item is mandatory for onboarding. /// Whether this item is mandatory for onboarding.
final bool isMandatory; 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 @override
List<Object?> get props => <Object?>[id, label, iconName, imageUrl, isMandatory]; List<Object?> get props => <Object?>[
id,
label,
description,
imageUrl,
isMandatory,
verificationStatus,
photoUrl,
verificationId,
];
} }

View File

@@ -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,
}

View File

@@ -11,7 +11,12 @@ import 'src/domain/usecases/delete_hub_usecase.dart';
import 'src/domain/usecases/get_hubs_usecase.dart'; import 'src/domain/usecases/get_hubs_usecase.dart';
import 'src/domain/usecases/update_hub_usecase.dart'; import 'src/domain/usecases/update_hub_usecase.dart';
import 'src/presentation/blocs/client_hubs_bloc.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/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'; export 'src/presentation/pages/client_hubs_page.dart';
@@ -34,10 +39,35 @@ class ClientHubsModule extends Module {
// BLoCs // BLoCs
i.add<ClientHubsBloc>(ClientHubsBloc.new); i.add<ClientHubsBloc>(ClientHubsBloc.new);
i.add<EditHubBloc>(EditHubBloc.new);
i.add<HubDetailsBloc>(HubDetailsBloc.new);
} }
@override @override
void routes(RouteManager r) { 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>(),
);
},
);
} }
} }

View File

@@ -2,69 +2,25 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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/get_hubs_usecase.dart';
import '../../domain/usecases/update_hub_usecase.dart';
import 'client_hubs_event.dart'; import 'client_hubs_event.dart';
import 'client_hubs_state.dart'; import 'client_hubs_state.dart';
/// BLoC responsible for managing the state of the Client Hubs feature. /// BLoC responsible for managing the state of the Client Hubs feature.
/// ///
/// It orchestrates the flow between the UI and the domain layer by invoking /// 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> class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
with BlocErrorHandler<ClientHubsState> with BlocErrorHandler<ClientHubsState>
implements Disposable { implements Disposable {
ClientHubsBloc({required GetHubsUseCase getHubsUseCase})
ClientHubsBloc({ : _getHubsUseCase = getHubsUseCase,
required GetHubsUseCase getHubsUseCase, super(const ClientHubsState()) {
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()) {
on<ClientHubsFetched>(_onFetched); on<ClientHubsFetched>(_onFetched);
on<ClientHubsAddRequested>(_onAddRequested);
on<ClientHubsUpdateRequested>(_onUpdateRequested);
on<ClientHubsDeleteRequested>(_onDeleteRequested);
on<ClientHubsNfcTagAssignRequested>(_onNfcTagAssignRequested);
on<ClientHubsMessageCleared>(_onMessageCleared); on<ClientHubsMessageCleared>(_onMessageCleared);
on<ClientHubsAddDialogToggled>(_onAddDialogToggled);
on<ClientHubsIdentifyDialogToggled>(_onIdentifyDialogToggled);
} }
final GetHubsUseCase _getHubsUseCase; 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( Future<void> _onFetched(
ClientHubsFetched event, ClientHubsFetched event,
@@ -75,7 +31,7 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
final List<Hub> hubs = await _getHubsUseCase(); final List<Hub> hubs = await _getHubsUseCase.call();
emit(state.copyWith(status: ClientHubsStatus.success, hubs: hubs)); emit(state.copyWith(status: ClientHubsStatus.success, hubs: hubs));
}, },
onError: (String errorKey) => state.copyWith( onError: (String errorKey) => state.copyWith(
@@ -85,141 +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,
),
);
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,
),
);
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( void _onMessageCleared(
ClientHubsMessageCleared event, ClientHubsMessageCleared event,
Emitter<ClientHubsState> emit, Emitter<ClientHubsState> emit,
@@ -229,8 +50,8 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
clearErrorMessage: true, clearErrorMessage: true,
clearSuccessMessage: true, clearSuccessMessage: true,
status: status:
state.status == ClientHubsStatus.actionSuccess || state.status == ClientHubsStatus.success ||
state.status == ClientHubsStatus.actionFailure state.status == ClientHubsStatus.failure
? ClientHubsStatus.success ? ClientHubsStatus.success
: state.status, : state.status,
), ),

View File

@@ -1,5 +1,4 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Base class for all client hubs events. /// Base class for all client hubs events.
abstract class ClientHubsEvent extends Equatable { abstract class ClientHubsEvent extends Equatable {
@@ -14,136 +13,7 @@ class ClientHubsFetched extends ClientHubsEvent {
const ClientHubsFetched(); 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,
});
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 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,
});
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,
];
}
/// 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. /// Event triggered to clear any error or success messages.
class ClientHubsMessageCleared extends ClientHubsEvent { class ClientHubsMessageCleared extends ClientHubsEvent {
const ClientHubsMessageCleared(); 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];
}

View File

@@ -2,47 +2,27 @@ import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// Enum representing the status of the client hubs state. /// Enum representing the status of the client hubs state.
enum ClientHubsStatus { enum ClientHubsStatus { initial, loading, success, failure }
initial,
loading,
success,
failure,
actionInProgress,
actionSuccess,
actionFailure,
}
/// State class for the ClientHubs BLoC. /// State class for the ClientHubs BLoC.
class ClientHubsState extends Equatable { class ClientHubsState extends Equatable {
const ClientHubsState({ const ClientHubsState({
this.status = ClientHubsStatus.initial, this.status = ClientHubsStatus.initial,
this.hubs = const <Hub>[], this.hubs = const <Hub>[],
this.errorMessage, this.errorMessage,
this.successMessage, this.successMessage,
this.showAddHubDialog = false,
this.hubToIdentify,
}); });
final ClientHubsStatus status; final ClientHubsStatus status;
final List<Hub> hubs; final List<Hub> hubs;
final String? errorMessage; final String? errorMessage;
final String? successMessage; 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({ ClientHubsState copyWith({
ClientHubsStatus? status, ClientHubsStatus? status,
List<Hub>? hubs, List<Hub>? hubs,
String? errorMessage, String? errorMessage,
String? successMessage, String? successMessage,
bool? showAddHubDialog,
Hub? hubToIdentify,
bool clearHubToIdentify = false,
bool clearErrorMessage = false, bool clearErrorMessage = false,
bool clearSuccessMessage = false, bool clearSuccessMessage = false,
}) { }) {
@@ -55,10 +35,6 @@ class ClientHubsState extends Equatable {
successMessage: clearSuccessMessage successMessage: clearSuccessMessage
? null ? null
: (successMessage ?? this.successMessage), : (successMessage ?? this.successMessage),
showAddHubDialog: showAddHubDialog ?? this.showAddHubDialog,
hubToIdentify: clearHubToIdentify
? null
: (hubToIdentify ?? this.hubToIdentify),
); );
} }
@@ -68,7 +44,5 @@ class ClientHubsState extends Equatable {
hubs, hubs,
errorMessage, errorMessage,
successMessage, successMessage,
showAddHubDialog,
hubToIdentify,
]; ];
} }

View File

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

View File

@@ -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,
];
}

View File

@@ -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];
}

View File

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

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -8,11 +8,10 @@ import 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart'; import '../blocs/client_hubs_bloc.dart';
import '../blocs/client_hubs_event.dart'; import '../blocs/client_hubs_event.dart';
import '../blocs/client_hubs_state.dart'; import '../blocs/client_hubs_state.dart';
import '../widgets/add_hub_dialog.dart';
import '../widgets/hub_card.dart'; import '../widgets/hub_card.dart';
import '../widgets/hub_empty_state.dart'; import '../widgets/hub_empty_state.dart';
import '../widgets/hub_info_card.dart'; import '../widgets/hub_info_card.dart';
import '../widgets/identify_nfc_dialog.dart';
/// The main page for the client hubs feature. /// The main page for the client hubs feature.
/// ///
@@ -43,7 +42,8 @@ class ClientHubsPage extends StatelessWidget {
context, context,
).add(const ClientHubsMessageCleared()); ).add(const ClientHubsMessageCleared());
} }
if (state.successMessage != null && state.successMessage!.isNotEmpty) { if (state.successMessage != null &&
state.successMessage!.isNotEmpty) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: state.successMessage!, message: state.successMessage!,
@@ -58,104 +58,67 @@ class ClientHubsPage extends StatelessWidget {
return Scaffold( return Scaffold(
backgroundColor: UiColors.bgMenu, backgroundColor: UiColors.bgMenu,
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => BlocProvider.of<ClientHubsBloc>( onPressed: () async {
context, final bool? success = await Modular.to.toEditHub();
).add(const ClientHubsAddDialogToggled(visible: true)), if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
child: const Icon(UiIcons.add), child: const Icon(UiIcons.add),
), ),
body: Stack( body: CustomScrollView(
children: <Widget>[ slivers: <Widget>[
CustomScrollView( _buildAppBar(context),
slivers: <Widget>[ SliverPadding(
_buildAppBar(context), padding: const EdgeInsets.symmetric(
SliverPadding( horizontal: UiConstants.space5,
padding: const EdgeInsets.symmetric( vertical: UiConstants.space5,
horizontal: UiConstants.space5, ).copyWith(bottom: 100),
vertical: UiConstants.space5, sliver: SliverList(
).copyWith(bottom: 100), delegate: SliverChildListDelegate(<Widget>[
sliver: SliverList( const Padding(
delegate: SliverChildListDelegate(<Widget>[ padding: EdgeInsets.only(bottom: UiConstants.space5),
if (state.status == ClientHubsStatus.loading) child: HubInfoCard(),
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(),
]),
), ),
),
], 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) { Widget _buildAppBar(BuildContext context) {
return SliverAppBar( return SliverAppBar(
backgroundColor: UiColors.foreground, // Dark Slate equivalent backgroundColor: UiColors.foreground,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
expandedHeight: 140, expandedHeight: 140,
pinned: true, 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),
),
],
);
},
);
}
} }

View File

@@ -2,28 +2,21 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart'; import '../blocs/edit_hub/edit_hub_bloc.dart';
import '../blocs/client_hubs_event.dart'; import '../blocs/edit_hub/edit_hub_event.dart';
import '../blocs/client_hubs_state.dart'; import '../blocs/edit_hub/edit_hub_state.dart';
import '../widgets/hub_address_autocomplete.dart'; import '../widgets/edit_hub/edit_hub_form_section.dart';
/// A dedicated full-screen page for editing an existing hub. /// A dedicated full-screen page for adding or editing a 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.
class EditHubPage extends StatefulWidget { class EditHubPage extends StatefulWidget {
const EditHubPage({ const EditHubPage({this.hub, required this.bloc, super.key});
required this.hub,
required this.bloc,
super.key,
});
final Hub hub; final Hub? hub;
final ClientHubsBloc bloc; final EditHubBloc bloc;
@override @override
State<EditHubPage> createState() => _EditHubPageState(); State<EditHubPage> createState() => _EditHubPageState();
@@ -39,9 +32,13 @@ class _EditHubPageState extends State<EditHubPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_nameController = TextEditingController(text: widget.hub.name); _nameController = TextEditingController(text: widget.hub?.name);
_addressController = TextEditingController(text: widget.hub.address); _addressController = TextEditingController(text: widget.hub?.address);
_addressFocusNode = FocusNode(); _addressFocusNode = FocusNode();
// Update header on change (if header is added back)
_nameController.addListener(() => setState(() {}));
_addressController.addListener(() => setState(() {}));
} }
@override @override
@@ -64,37 +61,50 @@ class _EditHubPageState extends State<EditHubPage> {
return; return;
} }
ReadContext(context).read<ClientHubsBloc>().add( if (widget.hub == null) {
ClientHubsUpdateRequested( widget.bloc.add(
id: widget.hub.id, EditHubAddRequested(
name: _nameController.text.trim(), name: _nameController.text.trim(),
address: _addressController.text.trim(), address: _addressController.text.trim(),
placeId: _selectedPrediction?.placeId, placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(_selectedPrediction?.lat ?? ''), latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
longitude: double.tryParse(_selectedPrediction?.lng ?? ''), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<ClientHubsBloc>.value( return BlocProvider<EditHubBloc>.value(
value: widget.bloc, value: widget.bloc,
child: BlocListener<ClientHubsBloc, ClientHubsState>( child: BlocListener<EditHubBloc, EditHubState>(
listenWhen: (ClientHubsState prev, ClientHubsState curr) => listenWhen: (EditHubState prev, EditHubState curr) =>
prev.status != curr.status || prev.successMessage != curr.successMessage, prev.status != curr.status ||
listener: (BuildContext context, ClientHubsState state) { prev.successMessage != curr.successMessage,
if (state.status == ClientHubsStatus.actionSuccess && listener: (BuildContext context, EditHubState state) {
if (state.status == EditHubStatus.success &&
state.successMessage != null) { state.successMessage != null) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: state.successMessage!, message: state.successMessage!,
type: UiSnackbarType.success, type: UiSnackbarType.success,
); );
// Pop back to details page with updated hub // Pop back to the previous screen.
Navigator.of(context).pop(true); Modular.to.pop(true);
} }
if (state.status == ClientHubsStatus.actionFailure && if (state.status == EditHubStatus.failure &&
state.errorMessage != null) { state.errorMessage != null) {
UiSnackbar.show( UiSnackbar.show(
context, context,
@@ -103,85 +113,43 @@ class _EditHubPageState extends State<EditHubPage> {
); );
} }
}, },
child: BlocBuilder<ClientHubsBloc, ClientHubsState>( child: BlocBuilder<EditHubBloc, EditHubState>(
builder: (BuildContext context, ClientHubsState state) { builder: (BuildContext context, EditHubState state) {
final bool isSaving = final bool isSaving = state.status == EditHubStatus.loading;
state.status == ClientHubsStatus.actionInProgress;
return Scaffold( return Scaffold(
backgroundColor: UiColors.bgMenu, backgroundColor: UiColors.bgMenu,
appBar: AppBar( appBar: UiAppBar(
backgroundColor: UiColors.foreground, title: widget.hub == null
leading: IconButton( ? t.client_hubs.add_hub_dialog.title
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), : t.client_hubs.edit_hub.title,
onPressed: () => Navigator.of(context).pop(), subtitle: widget.hub == null
), ? t.client_hubs.add_hub_dialog.create_button
title: Column( : t.client_hubs.edit_hub.subtitle,
crossAxisAlignment: CrossAxisAlignment.start, onLeadingPressed: () => Modular.to.pop(),
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),
),
),
],
),
), ),
body: Stack( body: Stack(
children: <Widget>[ children: <Widget>[
SingleChildScrollView( SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5), child: Column(
child: Form( crossAxisAlignment: CrossAxisAlignment.stretch,
key: _formKey, children: <Widget>[
child: Column( Padding(
crossAxisAlignment: CrossAxisAlignment.stretch, padding: const EdgeInsets.all(UiConstants.space5),
children: <Widget>[ child: EditHubFormSection(
// ── Name field ────────────────────────────────── formKey: _formKey,
_FieldLabel(t.client_hubs.edit_hub.name_label), nameController: _nameController,
TextFormField( addressController: _addressController,
controller: _nameController, addressFocusNode: _addressFocusNode,
style: UiTypography.body1r.textPrimary, onAddressSelected: (Prediction prediction) {
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 ────────────────────────────────
_FieldLabel(t.client_hubs.edit_hub.address_label),
HubAddressAutocomplete(
controller: _addressController,
hintText: t.client_hubs.edit_hub.address_hint,
focusNode: _addressFocusNode,
onSelected: (Prediction prediction) {
_selectedPrediction = 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),
],
),
), ),
), ),
@@ -199,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),
);
}
} }

View File

@@ -1,137 +1,134 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.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 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart'; import '../blocs/hub_details/hub_details_bloc.dart';
import 'edit_hub_page.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]. /// A read-only details page for a single [Hub].
/// ///
/// Shows hub name, address, and NFC tag assignment. /// 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 { class HubDetailsPage extends StatelessWidget {
const HubDetailsPage({ const HubDetailsPage({required this.hub, required this.bloc, super.key});
required this.hub,
required this.bloc,
super.key,
});
final Hub hub; final Hub hub;
final ClientHubsBloc bloc; final HubDetailsBloc bloc;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return BlocProvider<HubDetailsBloc>.value(
appBar: AppBar( value: bloc,
title: Text(hub.name), child: BlocListener<HubDetailsBloc, HubDetailsState>(
backgroundColor: UiColors.foreground, listener: (BuildContext context, HubDetailsState state) {
leading: IconButton( if (state.status == HubDetailsStatus.deleted) {
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), UiSnackbar.show(
onPressed: () => Navigator.of(context).pop(), context,
), message: state.successMessage ?? 'Hub deleted successfully',
actions: <Widget>[ type: UiSnackbarType.success,
TextButton.icon( );
onPressed: () => _navigateToEditPage(context), Modular.to.pop(true); // Return true to indicate change
icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16), }
label: Text( if (state.status == HubDetailsStatus.failure &&
t.client_hubs.hub_details.edit_button, state.errorMessage != null) {
style: const TextStyle(color: UiColors.white), UiSnackbar.show(
), context,
), message: state.errorMessage!,
], type: UiSnackbarType.error,
), );
backgroundColor: UiColors.bgMenu, }
body: Padding( },
padding: const EdgeInsets.all(UiConstants.space5), child: BlocBuilder<HubDetailsBloc, HubDetailsState>(
child: Column( builder: (BuildContext context, HubDetailsState state) {
crossAxisAlignment: CrossAxisAlignment.start, final bool isLoading = state.status == HubDetailsStatus.loading;
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.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,
),
],
),
),
);
}
Widget _buildDetailItem({ return Scaffold(
required String label, appBar: const UiAppBar(showBackButton: true),
required String value, bottomNavigationBar: HubDetailsBottomActions(
required IconData icon, isLoading: isLoading,
bool isHighlight = false, onDelete: () => _confirmDeleteHub(context),
}) { onEdit: () => _navigateToEditPage(context),
return Container( ),
padding: const EdgeInsets.all(UiConstants.space4), backgroundColor: UiColors.bgMenu,
decoration: BoxDecoration( body: Stack(
color: UiColors.white, children: <Widget>[
borderRadius: BorderRadius.circular(UiConstants.radiusBase), SingleChildScrollView(
boxShadow: const <BoxShadow>[ child: Column(
BoxShadow( crossAxisAlignment: CrossAxisAlignment.stretch,
color: UiColors.popupShadow, children: <Widget>[
blurRadius: 10, // ── Header ──────────────────────────────────────────
offset: Offset(0, 4), HubDetailsHeader(hub: hub),
), const Divider(height: 1, thickness: 0.5),
],
), Padding(
child: Row( padding: const EdgeInsets.all(UiConstants.space5),
children: <Widget>[ child: Column(
Container( crossAxisAlignment: CrossAxisAlignment.stretch,
padding: const EdgeInsets.all(UiConstants.space3), children: <Widget>[
decoration: BoxDecoration( HubDetailsItem(
color: isHighlight ? UiColors.tagInProgress : UiColors.bgInputField, label: t.client_hubs.hub_details.nfc_label,
borderRadius: BorderRadius.circular(UiConstants.radiusBase), value:
), hub.nfcTagId ??
child: Icon( t.client_hubs.hub_details.nfc_not_assigned,
icon, icon: UiIcons.nfc,
color: isHighlight ? UiColors.iconSuccess : UiColors.iconPrimary, isHighlight: hub.nfcTagId != null,
size: 20, ),
), ],
), ),
const SizedBox(width: UiConstants.space4), ),
Expanded( ],
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, ),
children: <Widget>[ if (isLoading)
Text(label, style: UiTypography.footnote1r.textSecondary), Container(
const SizedBox(height: UiConstants.space1), color: UiColors.black.withValues(alpha: 0.1),
Text(value, style: UiTypography.body1m.textPrimary), child: const Center(child: CircularProgressIndicator()),
], ),
), ],
), ),
], );
},
),
), ),
); );
} }
Future<void> _navigateToEditPage(BuildContext context) async { Future<void> _navigateToEditPage(BuildContext context) async {
// Navigate to the dedicated edit page and await result. final bool? saved = await Modular.to.toEditHub(hub: hub);
// If the page returns `true` (save succeeded), pop the details page too so if (saved == true && context.mounted) {
// the user sees the refreshed hub list (the BLoC already holds updated data). Modular.to.pop(true); // Return true to indicate change
final bool? saved = await Navigator.of(context).push<bool>( }
MaterialPageRoute<bool>( }
builder: (_) => EditHubPage(hub: hub, bloc: bloc),
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));
} }
} }
} }

View File

@@ -1,190 +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,
}) 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 _addressController;
late final FocusNode _addressFocusNode;
Prediction? _selectedPrediction;
@override
void initState() {
super.initState();
_nameController = TextEditingController();
_addressController = TextEditingController();
_addressFocusNode = FocusNode();
}
@override
void dispose() {
_nameController.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.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 ?? '',
),
);
}
},
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),
),
);
}
}

View File

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

View File

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

View File

@@ -5,115 +5,95 @@ import 'package:core_localization/core_localization.dart';
/// A card displaying information about a single hub. /// A card displaying information about a single hub.
class HubCard extends StatelessWidget { class HubCard extends StatelessWidget {
/// Creates a [HubCard]. /// Creates a [HubCard].
const HubCard({ const HubCard({required this.hub, required this.onTap, super.key});
required this.hub,
required this.onNfcPressed,
required this.onDeletePressed,
super.key,
});
/// The hub to display. /// The hub to display.
final Hub hub; final Hub hub;
/// Callback when the NFC button is pressed. /// Callback when the card is tapped.
final VoidCallback onNfcPressed; final VoidCallback onTap;
/// Callback when the delete button is pressed.
final VoidCallback onDeletePressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bool hasNfc = hub.nfcTagId != null; final bool hasNfc = hub.nfcTagId != null;
return Container( return GestureDetector(
margin: const EdgeInsets.only(bottom: UiConstants.space3), onTap: onTap,
decoration: BoxDecoration( child: Container(
color: UiColors.white, margin: const EdgeInsets.only(bottom: UiConstants.space3),
borderRadius: BorderRadius.circular(UiConstants.radiusBase), decoration: BoxDecoration(
boxShadow: const <BoxShadow>[ color: UiColors.white,
BoxShadow( borderRadius: BorderRadius.circular(UiConstants.radiusBase),
color: UiColors.popupShadow, border: Border.all(color: UiColors.border),
blurRadius: 10, ),
offset: Offset(0, 4), child: Padding(
), padding: const EdgeInsets.all(UiConstants.space4),
], child: Row(
), children: <Widget>[
child: Padding( Container(
padding: const EdgeInsets.all(UiConstants.space4), width: 52,
child: Row( height: 52,
children: <Widget>[ decoration: BoxDecoration(
Container( color: UiColors.tagInProgress,
width: 52, borderRadius: BorderRadius.circular(UiConstants.radiusBase),
height: 52, ),
decoration: BoxDecoration( child: Icon(
color: UiColors.tagInProgress, hasNfc ? UiIcons.success : UiIcons.nfc,
borderRadius: BorderRadius.circular(UiConstants.radiusBase), color: hasNfc ? UiColors.iconSuccess : UiColors.iconThird,
size: 24,
),
), ),
child: Icon( const SizedBox(width: UiConstants.space4),
hasNfc ? UiIcons.success : UiIcons.nfc, Expanded(
color: hasNfc ? UiColors.iconSuccess : UiColors.iconThird, child: Column(
size: 24, crossAxisAlignment: CrossAxisAlignment.start,
), children: <Widget>[
), Text(hub.name, style: UiTypography.body1b.textPrimary),
const SizedBox(width: UiConstants.space4), if (hub.address.isNotEmpty)
Expanded( Padding(
child: Column( padding: const EdgeInsets.only(top: UiConstants.space1),
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: <Widget>[ mainAxisSize: MainAxisSize.min,
Text(hub.name, style: UiTypography.body1b.textPrimary), children: <Widget>[
if (hub.address.isNotEmpty) const Icon(
Padding( UiIcons.mapPin,
padding: const EdgeInsets.only(top: UiConstants.space1), size: 12,
child: Row( color: UiColors.iconThird,
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.space1),
], Flexible(
), child: Text(
), hub.address,
if (hasNfc) style: UiTypography.footnote1r.textSecondary,
Padding( maxLines: 1,
padding: const EdgeInsets.only(top: UiConstants.space1), overflow: TextOverflow.ellipsis,
child: Text( ),
t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!), ),
style: UiTypography.footnote1b.copyWith( ],
color: UiColors.textSuccess,
fontFamily: 'monospace',
), ),
), ),
), if (hasNfc)
], Padding(
), padding: const EdgeInsets.only(top: UiConstants.space1),
), child: Text(
Row( t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!),
children: <Widget>[ style: UiTypography.footnote1b.copyWith(
IconButton( color: UiColors.textSuccess,
onPressed: onDeletePressed, fontFamily: 'monospace',
icon: const Icon( ),
UiIcons.delete, ),
color: UiColors.destructive, ),
size: 20, ],
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
), ),
], ),
), const Icon(
], UiIcons.chevronRight,
size: 16,
color: UiColors.iconSecondary,
),
],
),
), ),
), ),
); );

View File

@@ -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,
),
),
],
),
),
],
),
);
}
}

View File

@@ -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,
),
),
],
),
],
),
),
);
}
}

View File

@@ -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),
],
),
),
],
),
);
}
}

View File

@@ -31,10 +31,7 @@ class HubInfoCard extends StatelessWidget {
const SizedBox(height: UiConstants.space1), const SizedBox(height: UiConstants.space1),
Text( Text(
t.client_hubs.about_hubs.description, t.client_hubs.about_hubs.description,
style: UiTypography.footnote1r.copyWith( style: UiTypography.footnote1r.textSecondary,
color: UiColors.textSecondary,
height: 1.4,
),
), ),
], ],
), ),

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import '../../blocs/client_settings_bloc.dart'; import '../../blocs/client_settings_bloc.dart';
/// A widget that displays the primary actions for the settings page. /// A widget that displays the primary actions for the settings page.
@@ -27,10 +28,6 @@ class SettingsActions extends StatelessWidget {
_QuickLinksCard(labels: labels), _QuickLinksCard(labels: labels),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
// Notifications section
_NotificationsSettingsCard(),
const SizedBox(height: UiConstants.space4),
// Log Out button (outlined) // Log Out button (outlined)
BlocBuilder<ClientSettingsBloc, ClientSettingsState>( BlocBuilder<ClientSettingsBloc, ClientSettingsState>(
builder: (BuildContext context, ClientSettingsState state) { builder: (BuildContext context, ClientSettingsState state) {
@@ -80,15 +77,14 @@ class SettingsActions extends StatelessWidget {
/// Handles the sign-out button click event. /// Handles the sign-out button click event.
void _onSignoutClicked(BuildContext context) { void _onSignoutClicked(BuildContext context) {
ReadContext(context) ReadContext(
.read<ClientSettingsBloc>() context,
.add(const ClientSettingsSignOutRequested()); ).read<ClientSettingsBloc>().add(const ClientSettingsSignOutRequested());
} }
} }
/// Quick Links card — inline here since it's always part of SettingsActions ordering. /// Quick Links card — inline here since it's always part of SettingsActions ordering.
class _QuickLinksCard extends StatelessWidget { class _QuickLinksCard extends StatelessWidget {
const _QuickLinksCard({required this.labels}); const _QuickLinksCard({required this.labels});
final TranslationsClientSettingsProfileEn labels; final TranslationsClientSettingsProfileEn labels;
@@ -130,7 +126,6 @@ class _QuickLinksCard extends StatelessWidget {
/// A single quick link row item. /// A single quick link row item.
class _QuickLinkItem extends StatelessWidget { class _QuickLinkItem extends StatelessWidget {
const _QuickLinkItem({ const _QuickLinkItem({
required this.icon, required this.icon,
required this.title, required this.title,
@@ -198,24 +193,36 @@ class _NotificationsSettingsCard extends StatelessWidget {
icon: UiIcons.bell, icon: UiIcons.bell,
title: context.t.client_settings.preferences.push, title: context.t.client_settings.preferences.push,
value: state.pushEnabled, value: state.pushEnabled,
onChanged: (val) => ReadContext(context).read<ClientSettingsBloc>().add( onChanged: (val) =>
ClientSettingsNotificationToggled(type: 'push', isEnabled: val), ReadContext(context).read<ClientSettingsBloc>().add(
ClientSettingsNotificationToggled(
type: 'push',
isEnabled: val,
),
), ),
), ),
_NotificationToggle( _NotificationToggle(
icon: UiIcons.mail, icon: UiIcons.mail,
title: context.t.client_settings.preferences.email, title: context.t.client_settings.preferences.email,
value: state.emailEnabled, value: state.emailEnabled,
onChanged: (val) => ReadContext(context).read<ClientSettingsBloc>().add( onChanged: (val) =>
ClientSettingsNotificationToggled(type: 'email', isEnabled: val), ReadContext(context).read<ClientSettingsBloc>().add(
ClientSettingsNotificationToggled(
type: 'email',
isEnabled: val,
),
), ),
), ),
_NotificationToggle( _NotificationToggle(
icon: UiIcons.phone, icon: UiIcons.phone,
title: context.t.client_settings.preferences.sms, title: context.t.client_settings.preferences.sms,
value: state.smsEnabled, value: state.smsEnabled,
onChanged: (val) => ReadContext(context).read<ClientSettingsBloc>().add( onChanged: (val) =>
ClientSettingsNotificationToggled(type: 'sms', isEnabled: val), ReadContext(context).read<ClientSettingsBloc>().add(
ClientSettingsNotificationToggled(
type: 'sms',
isEnabled: val,
),
), ),
), ),
], ],

View File

@@ -12,7 +12,8 @@ class SettingsProfileHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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 dc.ClientSession? session = dc.ClientSessionStore.instance.session;
final String businessName = final String businessName =
session?.business?.businessName ?? 'Your Company'; session?.business?.businessName ?? 'Your Company';
@@ -26,9 +27,7 @@ class SettingsProfileHeader extends StatelessWidget {
child: Container( child: Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.only(bottom: 36), padding: const EdgeInsets.only(bottom: 36),
decoration: const BoxDecoration( decoration: const BoxDecoration(color: UiColors.primary),
color: UiColors.primary,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
@@ -75,13 +74,6 @@ class SettingsProfileHeader extends StatelessWidget {
color: UiColors.white.withValues(alpha: 0.6), color: UiColors.white.withValues(alpha: 0.6),
width: 3, width: 3,
), ),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.15),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
), ),
child: ClipOval( child: ClipOval(
child: photoUrl != null && photoUrl.isNotEmpty child: photoUrl != null && photoUrl.isNotEmpty
@@ -103,9 +95,7 @@ class SettingsProfileHeader extends StatelessWidget {
// ── Business Name ───────────────────────────────── // ── Business Name ─────────────────────────────────
Text( Text(
businessName, businessName,
style: UiTypography.headline3m.copyWith( style: UiTypography.headline3m.copyWith(color: UiColors.white),
color: UiColors.white,
),
), ),
const SizedBox(height: UiConstants.space2), 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),
),
),
),
], ],
), ),
), ),

View File

@@ -21,7 +21,9 @@ class OnboardingSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile; final TranslationsStaffProfileEn i18n = Translations.of(
context,
).staff.profile;
return BlocBuilder<ProfileCubit, ProfileState>( return BlocBuilder<ProfileCubit, ProfileState>(
builder: (BuildContext context, ProfileState state) { builder: (BuildContext context, ProfileState state) {
@@ -49,6 +51,11 @@ class OnboardingSection extends StatelessWidget {
completed: state.experienceComplete, completed: state.experienceComplete,
onTap: () => Modular.to.toExperience(), onTap: () => Modular.to.toExperience(),
), ),
ProfileMenuItem(
icon: UiIcons.shirt,
label: i18n.menu_items.attire,
onTap: () => Modular.to.toAttire(),
),
], ],
), ),
], ],

View File

@@ -1,12 +1,13 @@
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.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 'data/repositories_impl/attire_repository_impl.dart';
import 'domain/repositories/attire_repository.dart'; import 'domain/repositories/attire_repository.dart';
import 'domain/usecases/get_attire_options_usecase.dart'; import 'domain/usecases/get_attire_options_usecase.dart';
import 'domain/usecases/save_attire_usecase.dart'; import 'domain/usecases/save_attire_usecase.dart';
import 'domain/usecases/upload_attire_photo_usecase.dart'; import 'domain/usecases/upload_attire_photo_usecase.dart';
import 'presentation/blocs/attire_cubit.dart';
import 'presentation/pages/attire_page.dart'; import 'presentation/pages/attire_page.dart';
class StaffAttireModule extends Module { class StaffAttireModule extends Module {
@@ -22,6 +23,7 @@ class StaffAttireModule extends Module {
// BLoC // BLoC
i.addLazySingleton(AttireCubit.new); i.addLazySingleton(AttireCubit.new);
i.add(AttireCaptureCubit.new);
} }
@override @override

View File

@@ -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_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
@@ -6,30 +5,19 @@ import '../../domain/repositories/attire_repository.dart';
/// Implementation of [AttireRepository]. /// Implementation of [AttireRepository].
/// ///
/// Delegates data access to [DataConnectService]. /// Delegates data access to [StaffConnectorRepository].
class AttireRepositoryImpl implements AttireRepository { class AttireRepositoryImpl implements AttireRepository {
/// Creates an [AttireRepositoryImpl]. /// Creates an [AttireRepositoryImpl].
AttireRepositoryImpl({DataConnectService? service}) AttireRepositoryImpl({StaffConnectorRepository? connector})
: _service = service ?? DataConnectService.instance; : _connector =
/// The Data Connect service. connector ?? DataConnectService.instance.getStaffRepository();
final DataConnectService _service;
/// The Staff Connector repository.
final StaffConnectorRepository _connector;
@override @override
Future<List<AttireItem>> getAttireOptions() async { Future<List<AttireItem>> getAttireOptions() async {
return _service.run(() async { return _connector.getAttireOptions();
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();
});
} }
@override @override
@@ -37,16 +25,22 @@ class AttireRepositoryImpl implements AttireRepository {
required List<String> selectedItemIds, required List<String> selectedItemIds,
required Map<String, String> photoUrls, required Map<String, String> photoUrls,
}) async { }) async {
// TODO: Connect to actual backend mutation when available. // We already upsert photos in uploadPhoto (to follow the new flow).
// For now, simulate network delay as per prototype behavior. // This could save selections if there was a separate "SelectedAttire" table.
await Future<void>.delayed(const Duration(seconds: 1)); // For now, it's a no-op as the source of truth is the StaffAttire table.
} }
@override @override
Future<String> uploadPhoto(String itemId) async { Future<String> uploadPhoto(String itemId) async {
// TODO: Connect to actual storage service/mutation when available. // In a real app, this would upload to Firebase Storage first.
// For now, simulate upload delay and return mock URL. // Since the prototype returns a mock URL, we'll use that to upsert our record.
await Future<void>.delayed(const Duration(seconds: 1)); final String mockUrl = 'mock_url_for_$itemId';
return 'mock_url_for_$itemId';
await _connector.upsertStaffAttire(
attireOptionId: itemId,
photoUrl: mockUrl,
);
return mockUrl;
} }
} }

View File

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

View File

@@ -4,51 +4,51 @@ import 'package:krow_domain/krow_domain.dart';
enum AttireStatus { initial, loading, success, failure, saving, saved } enum AttireStatus { initial, loading, success, failure, saving, saved }
class AttireState extends Equatable { class AttireState extends Equatable {
const AttireState({ const AttireState({
this.status = AttireStatus.initial, this.status = AttireStatus.initial,
this.options = const <AttireItem>[], this.options = const <AttireItem>[],
this.selectedIds = const <String>[], this.selectedIds = const <String>[],
this.photoUrls = const <String, String>{}, this.photoUrls = const <String, String>{},
this.uploadingStatus = const <String, bool>{},
this.attestationChecked = false,
this.errorMessage, this.errorMessage,
}); });
final AttireStatus status; final AttireStatus status;
final List<AttireItem> options; final List<AttireItem> options;
final List<String> selectedIds; final List<String> selectedIds;
final Map<String, String> photoUrls; final Map<String, String> photoUrls;
final Map<String, bool> uploadingStatus;
final bool attestationChecked;
final String? errorMessage; final String? errorMessage;
bool get uploading => uploadingStatus.values.any((bool u) => u);
/// Helper to check if item is mandatory /// Helper to check if item is mandatory
bool isMandatory(String id) { 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 /// Validation logic
bool get allMandatorySelected { 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)); return mandatoryIds.every((String id) => selectedIds.contains(id));
} }
bool get allMandatoryHavePhotos { 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)); return mandatoryIds.every((String id) => photoUrls.containsKey(id));
} }
bool get canSave => allMandatorySelected && allMandatoryHavePhotos && attestationChecked && !uploading; bool get canSave => allMandatorySelected && allMandatoryHavePhotos;
AttireState copyWith({ AttireState copyWith({
AttireStatus? status, AttireStatus? status,
List<AttireItem>? options, List<AttireItem>? options,
List<String>? selectedIds, List<String>? selectedIds,
Map<String, String>? photoUrls, Map<String, String>? photoUrls,
Map<String, bool>? uploadingStatus,
bool? attestationChecked,
String? errorMessage, String? errorMessage,
}) { }) {
return AttireState( return AttireState(
@@ -56,20 +56,16 @@ class AttireState extends Equatable {
options: options ?? this.options, options: options ?? this.options,
selectedIds: selectedIds ?? this.selectedIds, selectedIds: selectedIds ?? this.selectedIds,
photoUrls: photoUrls ?? this.photoUrls, photoUrls: photoUrls ?? this.photoUrls,
uploadingStatus: uploadingStatus ?? this.uploadingStatus,
attestationChecked: attestationChecked ?? this.attestationChecked,
errorMessage: errorMessage, errorMessage: errorMessage,
); );
} }
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
status, status,
options, options,
selectedIds, selectedIds,
photoUrls, photoUrls,
uploadingStatus, errorMessage,
attestationChecked, ];
errorMessage
];
} }

View File

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

View File

@@ -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,
];
}

View File

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

View File

@@ -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);
},
),
),
),
),
],
);
},
),
);
},
),
);
}
}

View File

@@ -1,101 +1,143 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.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 '../widgets/attire_filter_chips.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_info_card.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}); const AttirePage({super.key});
@override
State<AttirePage> createState() => _AttirePageState();
}
class _AttirePageState extends State<AttirePage> {
String _filter = 'All';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Note: t.staff_profile_attire is available via re-export of core_localization
final AttireCubit cubit = Modular.get<AttireCubit>(); final AttireCubit cubit = Modular.get<AttireCubit>();
return BlocProvider<AttireCubit>.value( return Scaffold(
value: cubit, appBar: UiAppBar(
child: Scaffold( title: t.staff_profile_attire.title,
backgroundColor: UiColors.background, // FAFBFC showBackButton: true,
appBar: AppBar( ),
backgroundColor: UiColors.white, body: BlocProvider<AttireCubit>.value(
elevation: 0, value: cubit,
leading: IconButton( child: BlocConsumer<AttireCubit, AttireState>(
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>(
listener: (BuildContext context, AttireState state) { listener: (BuildContext context, AttireState state) {
if (state.status == AttireStatus.failure) { if (state.status == AttireStatus.failure) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: translateErrorKey(state.errorMessage ?? 'Error'), message: translateErrorKey(state.errorMessage ?? 'Error'),
type: UiSnackbarType.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) { builder: (BuildContext context, AttireState state) {
if (state.status == AttireStatus.loading && state.options.isEmpty) { if (state.status == AttireStatus.loading && state.options.isEmpty) {
return const Center(child: CircularProgressIndicator()); 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( return Column(
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
const AttireInfoCard(), const AttireInfoCard(),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
AttireGrid(
items: state.options, // Filter Chips
selectedIds: state.selectedIds, AttireFilterChips(
photoUrls: state.photoUrls, selectedFilter: _filter,
uploadingStatus: state.uploadingStatus, onFilterChanged: (String value) {
onToggle: cubit.toggleSelection, setState(() {
onUpload: cubit.uploadPhoto, _filter = value;
});
},
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
AttestationCheckbox(
isChecked: state.attestationChecked, // Item List
onChanged: (bool? val) => cubit.toggleAttestation(val ?? false), 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), const SizedBox(height: UiConstants.space20),
], ],
), ),
), ),
), ),
AttireBottomBar(
canSave: state.canSave,
allMandatorySelected: state.allMandatorySelected,
allMandatoryHavePhotos: state.allMandatoryHavePhotos,
attestationChecked: state.attestationChecked,
onSave: cubit.save,
),
], ],
); );
}, },

View File

@@ -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)],
),
),
),
),
);
}
}

View File

@@ -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),
),
),
],
);
}
}

View File

@@ -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),
),
],
),
),
],
),
);
}
}

View File

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

View File

@@ -5,7 +5,6 @@ import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
class AttireGrid extends StatelessWidget { class AttireGrid extends StatelessWidget {
const AttireGrid({ const AttireGrid({
super.key, super.key,
required this.items, required this.items,
@@ -53,7 +52,9 @@ class AttireGrid extends StatelessWidget {
) { ) {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? UiColors.primary.withOpacity(0.1) : Colors.transparent, color: isSelected
? UiColors.primary.withOpacity(0.1)
: Colors.transparent,
borderRadius: UiConstants.radiusSm, borderRadius: UiConstants.radiusSm,
border: Border.all( border: Border.all(
color: isSelected ? UiColors.primary : UiColors.border, color: isSelected ? UiColors.primary : UiColors.border,
@@ -67,17 +68,15 @@ class AttireGrid extends StatelessWidget {
top: UiConstants.space2, top: UiConstants.space2,
left: UiConstants.space2, left: UiConstants.space2,
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.destructive, // Red color: UiColors.destructive, // Red
borderRadius: UiConstants.radiusSm, borderRadius: UiConstants.radiusSm,
), ),
child: Text( child: Text(
t.staff_profile_attire.status.required, t.staff_profile_attire.status.required,
style: UiTypography.body3m.copyWith( // 12px Medium -> Bold style: UiTypography.body3m.copyWith(
// 12px Medium -> Bold
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 9, fontSize: 9,
color: UiColors.white, color: UiColors.white,
@@ -97,11 +96,7 @@ class AttireGrid extends StatelessWidget {
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Center( child: const Center(
child: Icon( child: Icon(UiIcons.check, color: UiColors.white, size: 12),
UiIcons.check,
color: UiColors.white,
size: 12,
),
), ),
), ),
), ),
@@ -119,26 +114,34 @@ class AttireGrid extends StatelessWidget {
height: 80, height: 80,
width: 80, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(UiConstants.radiusBase), borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
image: DecorationImage( image: DecorationImage(
image: NetworkImage(item.imageUrl!), image: NetworkImage(item.imageUrl!),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
), ),
) )
: Icon( : const Icon(
_getIcon(item.iconName), UiIcons.shirt,
size: 48, size: 48,
color: UiColors.textPrimary, // Was charcoal color: UiColors.iconSecondary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text( Text(
item.label, item.label,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: UiTypography.body2m.copyWith( style: UiTypography.body2m.textPrimary,
color: UiColors.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( border: Border.all(
color: hasPhoto ? UiColors.primary : UiColors.border, color: hasPhoto ? UiColors.primary : UiColors.border,
), ),
borderRadius: BorderRadius.circular(UiConstants.radiusBase), borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -169,7 +174,9 @@ class AttireGrid extends StatelessWidget {
height: 12, height: 12,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(UiColors.primary), valueColor: AlwaysStoppedAnimation<Color>(
UiColors.primary,
),
), ),
) )
else if (hasPhoto) else if (hasPhoto)
@@ -189,10 +196,12 @@ class AttireGrid extends StatelessWidget {
isUploading isUploading
? '...' ? '...'
: hasPhoto : hasPhoto
? t.staff_profile_attire.status.added ? t.staff_profile_attire.status.added
: t.staff_profile_attire.status.add_photo, : t.staff_profile_attire.status.add_photo,
style: UiTypography.body3m.copyWith( 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;
}
}
} }

View File

@@ -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,
),
],
),
],
),
),
);
}
}

View File

@@ -1,7 +1,7 @@
mutation createAttireOption( mutation createAttireOption(
$itemId: String! $itemId: String!
$label: String! $label: String!
$icon: String $description: String
$imageUrl: String $imageUrl: String
$isMandatory: Boolean $isMandatory: Boolean
$vendorId: UUID $vendorId: UUID
@@ -10,7 +10,7 @@ mutation createAttireOption(
data: { data: {
itemId: $itemId itemId: $itemId
label: $label label: $label
icon: $icon description: $description
imageUrl: $imageUrl imageUrl: $imageUrl
isMandatory: $isMandatory isMandatory: $isMandatory
vendorId: $vendorId vendorId: $vendorId
@@ -22,7 +22,7 @@ mutation updateAttireOption(
$id: UUID! $id: UUID!
$itemId: String $itemId: String
$label: String $label: String
$icon: String $description: String
$imageUrl: String $imageUrl: String
$isMandatory: Boolean $isMandatory: Boolean
$vendorId: UUID $vendorId: UUID
@@ -32,7 +32,7 @@ mutation updateAttireOption(
data: { data: {
itemId: $itemId itemId: $itemId
label: $label label: $label
icon: $icon description: $description
imageUrl: $imageUrl imageUrl: $imageUrl
isMandatory: $isMandatory isMandatory: $isMandatory
vendorId: $vendorId vendorId: $vendorId

View File

@@ -3,7 +3,7 @@ query listAttireOptions @auth(level: USER) {
id id
itemId itemId
label label
icon description
imageUrl imageUrl
isMandatory isMandatory
vendorId vendorId
@@ -16,7 +16,7 @@ query getAttireOptionById($id: UUID!) @auth(level: USER) {
id id
itemId itemId
label label
icon description
imageUrl imageUrl
isMandatory isMandatory
vendorId vendorId
@@ -39,7 +39,7 @@ query filterAttireOptions(
id id
itemId itemId
label label
icon description
imageUrl imageUrl
isMandatory isMandatory
vendorId vendorId

View File

@@ -0,0 +1,16 @@
mutation upsertStaffAttire(
$staffId: UUID!
$attireOptionId: UUID!
$verificationPhotoUrl: String
$verificationId: String
) @auth(level: USER) {
staffAttire_upsert(
data: {
staffId: $staffId
attireOptionId: $attireOptionId
verificationPhotoUrl: $verificationPhotoUrl
verificationId: $verificationId
verificationStatus: PENDING
}
)
}

View File

@@ -0,0 +1,8 @@
query getStaffAttire($staffId: UUID!) @auth(level: USER) {
staffAttires(where: { staffId: { eq: $staffId } }) {
attireOptionId
verificationStatus
verificationPhotoUrl
verificationId
}
}

View File

@@ -0,0 +1,3 @@
mutation cleanAttireOptions @transaction {
attireOption_deleteMany(all: true)
}

View File

@@ -1770,5 +1770,163 @@ mutation seedAll @transaction {
invoiceId: "ba0529be-7906-417f-8ec7-c866d0633fee" invoiceId: "ba0529be-7906-417f-8ec7-c866d0633fee"
} }
) )
# Attire Options (Required)
attire_1: attireOption_insert(
data: {
id: "4bce6592-e38e-4d90-a478-d1ce0f286146"
itemId: "shoes_non_slip"
label: "Non Slip Shoes"
description: "Black, closed-toe, non-slip work shoes."
imageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: true
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_2: attireOption_insert(
data: {
id: "786e9761-b398-42bd-b363-91a40938864e"
itemId: "pants_black"
label: "Black Pants"
description: "Professional black slacks or trousers. No jeans."
imageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: true
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_3: attireOption_insert(
data: {
id: "17b135e6-b8f0-4541-b12b-505e95de31ef"
itemId: "socks_black"
label: "Black Socks"
description: "Solid black dress or crew socks."
imageUrl: "https://images.unsplash.com/photo-1582966298431-99c6a1e8d44e?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: true
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_4: attireOption_insert(
data: {
id: "bbff61b3-3f99-4637-9a2f-1d4c6fa61517"
itemId: "shirt_white_button_up"
label: "White Button Up"
description: "Clean, pressed, long-sleeve white button-up shirt."
imageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: true
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
# Attire Options (Non-Essential)
attire_5: attireOption_insert(
data: {
id: "32e77813-24f5-495b-98de-872e33073820"
itemId: "pants_blue_jeans"
label: "Blue Jeans"
description: "Standard blue denim jeans, no rips or tears."
imageUrl: "https://images.unsplash.com/photo-1542272604-787c3835535d?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_6: attireOption_insert(
data: {
id: "de3c5a90-2c88-4c87-bb00-b62c6460d506"
itemId: "shirt_white_polo"
label: "White Polo"
description: "White polo shirt with collar."
imageUrl: "https://images.unsplash.com/photo-1581655353564-df123a1eb820?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_7: attireOption_insert(
data: {
id: "64149864-b886-4a00-9aa2-09903a401b5b"
itemId: "shirt_catering"
label: "Catering Shirt"
description: "Company approved catering staff shirt."
imageUrl: "https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_8: attireOption_insert(
data: {
id: "9b2e493e-e95c-4dcd-9073-e42dbcf77076"
itemId: "banquette"
label: "Banquette"
description: "Standard banquette or event setup uniform."
imageUrl: "https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_9: attireOption_insert(
data: {
id: "2e30cde5-5acd-4dd0-b8e9-af6d6b59b248"
itemId: "hat_black_cap"
label: "Black Cap"
description: "Plain black baseball cap, no logos."
imageUrl: "https://images.unsplash.com/photo-1588850561407-ed78c282e89b?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_10: attireOption_insert(
data: {
id: "90d912ed-1227-44ef-ae75-bc7ca2c491c6"
itemId: "chef_coat"
label: "Chef Coat"
description: "Standard white double-breasted chef coat."
imageUrl: "https://images.unsplash.com/photo-1583394293214-28ded15ee548?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_11: attireOption_insert(
data: {
id: "d857d96b-5bf4-4648-bb9c-f909436729fd"
itemId: "shirt_black_button_up"
label: "Black Button Up"
description: "Clean, pressed, long-sleeve black button-up shirt."
imageUrl: "https://images.unsplash.com/photo-1598033129183-c4f50c7176c8?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_12: attireOption_insert(
data: {
id: "1f61267b-1f7a-43f1-bfd7-2a018347285b"
itemId: "shirt_black_polo"
label: "Black Polo"
description: "Black polo shirt with collar."
imageUrl: "https://images.unsplash.com/photo-1583743814966-8936f5b7be1a?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_13: attireOption_insert(
data: {
id: "16192098-e5ec-4bf2-86d3-c693663BA687"
itemId: "all_black_bistro"
label: "All Black Bistro"
description: "Full black bistro uniform including apron."
imageUrl: "https://images.unsplash.com/photo-1551632432-c735e8399527?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_14: attireOption_insert(
data: {
id: "6be15ab9-6c73-453b-950b-d4ba35d875de"
itemId: "white_black_bistro"
label: "White and Black Bistro"
description: "White shirt with black pants and bistro apron."
imageUrl: "https://images.unsplash.com/photo-1600565193348-f74bd3c7ccdf?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
} }
#v.3 #v.3

View File

@@ -0,0 +1,159 @@
mutation seedAttireOptions @transaction {
# Attire Options (Required)
attire_1: attireOption_upsert(
data: {
id: "4bce6592-e38e-4d90-a478-d1ce0f286146"
itemId: "shoes_non_slip"
label: "Non Slip Shoes"
description: "Black, closed-toe, non-slip work shoes."
imageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: true
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_2: attireOption_upsert(
data: {
id: "786e9761-b398-42bd-b363-91a40938864e"
itemId: "pants_black"
label: "Black Pants"
description: "Professional black slacks or trousers. No jeans."
imageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: true
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_3: attireOption_upsert(
data: {
id: "17b135e6-b8f0-4541-b12b-505e95de31ef"
itemId: "socks_black"
label: "Black Socks"
description: "Solid black dress or crew socks."
imageUrl: "https://images.unsplash.com/photo-1582966298431-99c6a1e8d44e?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: true
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_4: attireOption_upsert(
data: {
id: "bbff61b3-3f99-4637-9a2f-1d4c6fa61517"
itemId: "shirt_white_button_up"
label: "White Button Up"
description: "Clean, pressed, long-sleeve white button-up shirt."
imageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: true
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
# Attire Options (Non-Essential)
attire_5: attireOption_upsert(
data: {
id: "32e77813-24f5-495b-98de-872e33073820"
itemId: "pants_blue_jeans"
label: "Blue Jeans"
description: "Standard blue denim jeans, no rips or tears."
imageUrl: "https://images.unsplash.com/photo-1542272604-787c3835535d?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_6: attireOption_upsert(
data: {
id: "de3c5a90-2c88-4c87-bb00-b62c6460d506"
itemId: "shirt_white_polo"
label: "White Polo"
description: "White polo shirt with collar."
imageUrl: "https://images.unsplash.com/photo-1581655353564-df123a1eb820?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_7: attireOption_upsert(
data: {
id: "64149864-b886-4a00-9aa2-09903a401b5b"
itemId: "shirt_catering"
label: "Catering Shirt"
description: "Company approved catering staff shirt."
imageUrl: "https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_8: attireOption_upsert(
data: {
id: "9b2e493e-e95c-4dcd-9073-e42dbcf77076"
itemId: "banquette"
label: "Banquette"
description: "Standard banquette or event setup uniform."
imageUrl: "https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_9: attireOption_upsert(
data: {
id: "2e30cde5-5acd-4dd0-b8e9-af6d6b59b248"
itemId: "hat_black_cap"
label: "Black Cap"
description: "Plain black baseball cap, no logos."
imageUrl: "https://images.unsplash.com/photo-1588850561407-ed78c282e89b?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_10: attireOption_upsert(
data: {
id: "90d912ed-1227-44ef-ae75-bc7ca2c491c6"
itemId: "chef_coat"
label: "Chef Coat"
description: "Standard white double-breasted chef coat."
imageUrl: "https://images.unsplash.com/photo-1583394293214-28ded15ee548?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_11: attireOption_upsert(
data: {
id: "d857d96b-5bf4-4648-bb9c-f909436729fd"
itemId: "shirt_black_button_up"
label: "Black Button Up"
description: "Clean, pressed, long-sleeve black button-up shirt."
imageUrl: "https://images.unsplash.com/photo-1598033129183-c4f50c7176c8?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_12: attireOption_upsert(
data: {
id: "1f61267b-1f7a-43f1-bfd7-2a018347285b"
itemId: "shirt_black_polo"
label: "Black Polo"
description: "Black polo shirt with collar."
imageUrl: "https://images.unsplash.com/photo-1583743814966-8936f5b7be1a?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_13: attireOption_upsert(
data: {
id: "16192098-e5ec-4bf2-86d3-c693663BA687"
itemId: "all_black_bistro"
label: "All Black Bistro"
description: "Full black bistro uniform including apron."
imageUrl: "https://images.unsplash.com/photo-1551632432-c735e8399527?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
attire_14: attireOption_upsert(
data: {
id: "6be15ab9-6c73-453b-950b-d4ba35d875de"
itemId: "white_black_bistro"
label: "White and Black Bistro"
description: "White shirt with black pants and bistro apron."
imageUrl: "https://images.unsplash.com/photo-1600565193348-f74bd3c7ccdf?auto=format&fit=crop&q=80&w=400&h=400"
isMandatory: false
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
}
)
}

View File

@@ -2,7 +2,7 @@ type AttireOption @table(name: "attire_options") {
id: UUID! @default(expr: "uuidV4()") id: UUID! @default(expr: "uuidV4()")
itemId: String! itemId: String!
label: String! label: String!
icon: String description: String
imageUrl: String imageUrl: String
isMandatory: Boolean isMandatory: Boolean

View File

@@ -0,0 +1,22 @@
enum AttireVerificationStatus {
PENDING
FAILED
SUCCESS
}
type StaffAttire @table(name: "staff_attires", key: ["staffId", "attireOptionId"]) {
staffId: UUID!
staff: Staff! @ref(fields: "staffId", references: "id")
attireOptionId: UUID!
attireOption: AttireOption! @ref(fields: "attireOptionId", references: "id")
# Verification Metadata
verificationStatus: AttireVerificationStatus @default(expr: "'PENDING'")
verifiedAt: Timestamp
verificationPhotoUrl: String # Proof of ownership
verificationId: String
createdAt: Timestamp @default(expr: "request.time")
updatedAt: Timestamp @default(expr: "request.time")
}

207
scripts/create_issues.py Normal file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python3
import subprocess
import os
import re
import argparse
# --- Configuration ---
INPUT_FILE = "issues-to-create.md"
DEFAULT_PROJECT_TITLE = None
DEFAULT_MILESTONE = "Milestone 4"
# ---
def parse_issues(content):
"""Parse issue blocks from markdown content.
Each issue block starts with a '# Title' line, followed by an optional
'Labels:' metadata line, then the body. Milestone is set globally, not per-issue.
"""
issue_blocks = re.split(r'\n(?=#\s)', content)
issues = []
for block in issue_blocks:
if not block.strip():
continue
lines = block.strip().split('\n')
# Title: strip leading '#' characters and whitespace
title = re.sub(r'^#+\s*', '', lines[0]).strip()
labels_line = ""
body_start_index = len(lines) # default: no body
# Only 'Labels:' is parsed from the markdown; milestone is global
for i, line in enumerate(lines[1:], start=1):
stripped = line.strip()
if stripped.lower().startswith('labels:'):
labels_line = stripped.split(':', 1)[1].strip()
elif stripped == "":
continue # skip blank separator lines in the header
else:
body_start_index = i
break
body = "\n".join(lines[body_start_index:]).strip()
labels = [label.strip() for label in labels_line.split(',') if label.strip()]
if not title:
print("⚠️ Skipping block with no title.")
continue
issues.append({
"title": title,
"body": body,
"labels": labels,
})
return issues
def main():
parser = argparse.ArgumentParser(
description="Bulk create GitHub issues from a markdown file.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Input file format (issues-to-create.md):
-----------------------------------------
# Issue Title One
Labels: bug, enhancement
This is the body of the first issue.
It can span multiple lines.
# Issue Title Two
Labels: documentation
Body of the second issue.
-----------------------------------------
All issues share the same project and milestone, configured at the top of this script
or passed via --project and --milestone flags.
"""
)
parser.add_argument(
"--file", "-f",
default=INPUT_FILE,
help=f"Path to the markdown input file (default: {INPUT_FILE})"
)
parser.add_argument(
"--project", "-p",
default=DEFAULT_PROJECT_TITLE,
help=f"GitHub Project title for all issues (default: {DEFAULT_PROJECT_TITLE})"
)
parser.add_argument(
"--milestone", "-m",
default=DEFAULT_MILESTONE,
help=f"Milestone to assign to all issues (default: {DEFAULT_MILESTONE})"
)
parser.add_argument(
"--no-project",
action="store_true",
help="Do not add issues to any project."
)
parser.add_argument(
"--no-milestone",
action="store_true",
help="Do not assign a milestone to any issue."
)
parser.add_argument(
"--repo", "-r",
default=None,
help="Target GitHub repo in OWNER/REPO format (uses gh default if not set)."
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Parse the file and print issues without creating them."
)
args = parser.parse_args()
input_file = args.file
project_title = args.project if not args.no_project else None
milestone = args.milestone if not args.no_milestone else None
print("🚀 Bulk GitHub Issue Creator")
print("=" * 40)
print(f" Input file: {input_file}")
print(f" Project: {project_title or '(none)'}")
print(f" Milestone: {milestone or '(none)'}")
if args.repo:
print(f" Repo: {args.repo}")
if args.dry_run:
print(" Mode: DRY RUN (no issues will be created)")
print("=" * 40)
# --- Preflight checks ---
if subprocess.run(["which", "gh"], capture_output=True).returncode != 0:
print("❌ ERROR: GitHub CLI ('gh') is not installed or not in PATH.")
print(" Install it from: https://cli.github.com/")
exit(1)
if not os.path.exists(input_file):
print(f"❌ ERROR: Input file '{input_file}' not found.")
exit(1)
print("✅ Preflight checks passed.\n")
# --- Parse ---
print(f"📄 Parsing '{input_file}'...")
with open(input_file, 'r') as f:
content = f.read()
issues = parse_issues(content)
if not issues:
print("⚠️ No issues found in the input file. Check the format.")
exit(0)
print(f" Found {len(issues)} issue(s) to create.\n")
# --- Create ---
success_count = 0
fail_count = 0
for idx, issue in enumerate(issues, start=1):
print(f"[{idx}/{len(issues)}] {issue['title']}")
if issue['labels']:
print(f" Labels: {', '.join(issue['labels'])}")
print(f" Milestone: {milestone or '(none)'}")
print(f" Project: {project_title or '(none)'}")
if args.dry_run:
print(" (dry-run — skipping creation)\n")
continue
command = ["gh", "issue", "create"]
if args.repo:
command.extend(["--repo", args.repo])
command.extend(["--title", issue["title"]])
command.extend(["--body", issue["body"] or " "]) # gh requires non-empty body
if project_title:
command.extend(["--project", project_title])
if milestone:
command.extend(["--milestone", milestone])
for label in issue["labels"]:
command.extend(["--label", label])
try:
result = subprocess.run(command, check=True, text=True, capture_output=True)
print(f" ✅ Created: {result.stdout.strip()}")
success_count += 1
except subprocess.CalledProcessError as e:
print(f" ❌ Failed: {e.stderr.strip()}")
fail_count += 1
print()
# --- Summary ---
print("=" * 40)
if args.dry_run:
print(f"🔍 Dry run complete. {len(issues)} issue(s) parsed, none created.")
else:
print(f"🎉 Done! {success_count} created, {fail_count} failed.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,27 @@
# <Sample Title>
Labels: <platform:web, platform:infrastructure, feature, priority:high>
<Sample Description>
## Scope
### <Sample Sub-section>
- <Sample Description>
## <Sample Acceptance Criteria>
- [ ] <Sample Description>
-------
# <Sample Title 2>
Labels: <platform:web, platform:infrastructure, feature, priority:high>
<Sample Description>
## Scope
### <Sample Sub-section>
- <Sample Description>
## <Sample Acceptance Criteria>
- [ ] <Sample Description>