diff --git a/apps/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/packages/core_localization/lib/src/l10n/en.i18n.json index 1d556eab..01ba7faf 100644 --- a/apps/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/packages/core_localization/lib/src/l10n/en.i18n.json @@ -196,6 +196,40 @@ "clock_in_hubs": "Clock-In Hubs", "billing_payments": "Billing & Payments" } + }, + "client_hubs": { + "title": "Hubs", + "subtitle": "Manage clock-in locations", + "add_hub": "Add Hub", + "empty_state": { + "title": "No hubs yet", + "description": "Create clock-in stations for your locations", + "button": "Add Your First Hub" + }, + "about_hubs": { + "title": "About Hubs", + "description": "Hubs are clock-in stations at your locations. Assign NFC tags to each hub so workers can quickly clock in/out using their phones." + }, + "hub_card": { + "tag_label": "Tag: $id" + }, + "add_hub_dialog": { + "title": "Add New Hub", + "name_label": "Hub Name *", + "name_hint": "e.g., Main Kitchen, Front Desk", + "location_label": "Location Name", + "location_hint": "e.g., Downtown Restaurant", + "address_label": "Address", + "address_hint": "Full address", + "create_button": "Create Hub" + }, + "nfc_dialog": { + "title": "Identify NFC Tag", + "instruction": "Tap your phone to the NFC tag to identify it", + "scan_button": "Scan NFC Tag", + "tag_identified": "Tag Identified", + "assign_button": "Assign Tag" + } } } diff --git a/apps/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/packages/core_localization/lib/src/l10n/es.i18n.json index c596400d..b114e2ad 100644 --- a/apps/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/packages/core_localization/lib/src/l10n/es.i18n.json @@ -196,5 +196,39 @@ "clock_in_hubs": "Hubs de Marcaje", "billing_payments": "Facturación y Pagos" } + }, + "client_hubs": { + "title": "Hubs", + "subtitle": "Gestionar ubicaciones de marcaje", + "add_hub": "Añadir Hub", + "empty_state": { + "title": "No hay hubs aún", + "description": "Crea estaciones de marcaje para tus ubicaciones", + "button": "Añade tu primer Hub" + }, + "about_hubs": { + "title": "Sobre los Hubs", + "description": "Los Hubs son estaciones de marcaje en tus ubicaciones. Asigna etiquetas NFC a cada hub para que los trabajadores puedan marcar entrada/salida rápidamente usando sus teléfonos." + }, + "hub_card": { + "tag_label": "Etiqueta: $id" + }, + "add_hub_dialog": { + "title": "Añadir Nuevo Hub", + "name_label": "Nombre del Hub *", + "name_hint": "ej., Cocina Principal, Recepción", + "location_label": "Nombre de la Ubicación", + "location_hint": "ej., Restaurante Centro", + "address_label": "Dirección", + "address_hint": "Dirección completa", + "create_button": "Crear Hub" + }, + "nfc_dialog": { + "title": "Identificar Etiqueta NFC", + "instruction": "Acerque su teléfono a la etiqueta NFC para identificarla", + "scan_button": "Escanear Etiqueta NFC", + "tag_identified": "Etiqueta Identificada", + "assign_button": "Asignar Etiqueta" + } } } diff --git a/apps/packages/core_localization/lib/src/l10n/strings.g.dart b/apps/packages/core_localization/lib/src/l10n/strings.g.dart index 9fb8794c..b377b0ea 100644 --- a/apps/packages/core_localization/lib/src/l10n/strings.g.dart +++ b/apps/packages/core_localization/lib/src/l10n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 2 -/// Strings: 288 (144 per locale) +/// Strings: 332 (166 per locale) /// -/// Built on 2026-01-22 at 00:33 UTC +/// Built on 2026-01-22 at 00:48 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/packages/core_localization/lib/src/l10n/strings_en.g.dart b/apps/packages/core_localization/lib/src/l10n/strings_en.g.dart index e02e5085..e42d42a3 100644 --- a/apps/packages/core_localization/lib/src/l10n/strings_en.g.dart +++ b/apps/packages/core_localization/lib/src/l10n/strings_en.g.dart @@ -46,6 +46,7 @@ class Translations with BaseTranslations { late final TranslationsClientAuthenticationEn client_authentication = TranslationsClientAuthenticationEn._(_root); late final TranslationsClientHomeEn client_home = TranslationsClientHomeEn._(_root); late final TranslationsClientSettingsEn client_settings = TranslationsClientSettingsEn._(_root); + late final TranslationsClientHubsEn client_hubs = TranslationsClientHubsEn._(_root); } // Path: common @@ -138,6 +139,30 @@ class TranslationsClientSettingsEn { late final TranslationsClientSettingsProfileEn profile = TranslationsClientSettingsProfileEn._(_root); } +// Path: client_hubs +class TranslationsClientHubsEn { + TranslationsClientHubsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Hubs' + String get title => 'Hubs'; + + /// en: 'Manage clock-in locations' + String get subtitle => 'Manage clock-in locations'; + + /// en: 'Add Hub' + String get add_hub => 'Add Hub'; + + late final TranslationsClientHubsEmptyStateEn empty_state = TranslationsClientHubsEmptyStateEn._(_root); + late final TranslationsClientHubsAboutHubsEn about_hubs = TranslationsClientHubsAboutHubsEn._(_root); + late final TranslationsClientHubsHubCardEn hub_card = TranslationsClientHubsHubCardEn._(_root); + late final TranslationsClientHubsAddHubDialogEn add_hub_dialog = TranslationsClientHubsAddHubDialogEn._(_root); + late final TranslationsClientHubsNfcDialogEn nfc_dialog = TranslationsClientHubsNfcDialogEn._(_root); +} + // Path: staff_authentication.get_started_page class TranslationsStaffAuthenticationGetStartedPageEn { TranslationsStaffAuthenticationGetStartedPageEn._(this._root); @@ -578,6 +603,108 @@ class TranslationsClientSettingsProfileEn { String get billing_payments => 'Billing & Payments'; } +// Path: client_hubs.empty_state +class TranslationsClientHubsEmptyStateEn { + TranslationsClientHubsEmptyStateEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'No hubs yet' + String get title => 'No hubs yet'; + + /// en: 'Create clock-in stations for your locations' + String get description => 'Create clock-in stations for your locations'; + + /// en: 'Add Your First Hub' + String get button => 'Add Your First Hub'; +} + +// Path: client_hubs.about_hubs +class TranslationsClientHubsAboutHubsEn { + TranslationsClientHubsAboutHubsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'About Hubs' + String get title => 'About Hubs'; + + /// en: 'Hubs are clock-in stations at your locations. Assign NFC tags to each hub so workers can quickly clock in/out using their phones.' + String get description => 'Hubs are clock-in stations at your locations. Assign NFC tags to each hub so workers can quickly clock in/out using their phones.'; +} + +// Path: client_hubs.hub_card +class TranslationsClientHubsHubCardEn { + TranslationsClientHubsHubCardEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Tag: $id' + String tag_label({required Object id}) => 'Tag: ${id}'; +} + +// Path: client_hubs.add_hub_dialog +class TranslationsClientHubsAddHubDialogEn { + TranslationsClientHubsAddHubDialogEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Add New Hub' + String get title => 'Add New Hub'; + + /// en: 'Hub Name *' + String get name_label => 'Hub Name *'; + + /// en: 'e.g., Main Kitchen, Front Desk' + String get name_hint => 'e.g., Main Kitchen, Front Desk'; + + /// en: 'Location Name' + String get location_label => 'Location Name'; + + /// en: 'e.g., Downtown Restaurant' + String get location_hint => 'e.g., Downtown Restaurant'; + + /// en: 'Address' + String get address_label => 'Address'; + + /// en: 'Full address' + String get address_hint => 'Full address'; + + /// en: 'Create Hub' + String get create_button => 'Create Hub'; +} + +// Path: client_hubs.nfc_dialog +class TranslationsClientHubsNfcDialogEn { + TranslationsClientHubsNfcDialogEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Identify NFC Tag' + String get title => 'Identify NFC Tag'; + + /// en: 'Tap your phone to the NFC tag to identify it' + String get instruction => 'Tap your phone to the NFC tag to identify it'; + + /// en: 'Scan NFC Tag' + String get scan_button => 'Scan NFC Tag'; + + /// en: 'Tag Identified' + String get tag_identified => 'Tag Identified'; + + /// en: 'Assign Tag' + String get assign_button => 'Assign Tag'; +} + // Path: staff_authentication.profile_setup_page.steps class TranslationsStaffAuthenticationProfileSetupPageStepsEn { TranslationsStaffAuthenticationProfileSetupPageStepsEn._(this._root); @@ -898,6 +1025,28 @@ extension on Translations { 'client_settings.profile.quick_links' => 'Quick Links', 'client_settings.profile.clock_in_hubs' => 'Clock-In Hubs', 'client_settings.profile.billing_payments' => 'Billing & Payments', + 'client_hubs.title' => 'Hubs', + 'client_hubs.subtitle' => 'Manage clock-in locations', + 'client_hubs.add_hub' => 'Add Hub', + 'client_hubs.empty_state.title' => 'No hubs yet', + 'client_hubs.empty_state.description' => 'Create clock-in stations for your locations', + 'client_hubs.empty_state.button' => 'Add Your First Hub', + 'client_hubs.about_hubs.title' => 'About Hubs', + 'client_hubs.about_hubs.description' => 'Hubs are clock-in stations at your locations. Assign NFC tags to each hub so workers can quickly clock in/out using their phones.', + 'client_hubs.hub_card.tag_label' => ({required Object id}) => 'Tag: ${id}', + 'client_hubs.add_hub_dialog.title' => 'Add New Hub', + 'client_hubs.add_hub_dialog.name_label' => 'Hub Name *', + 'client_hubs.add_hub_dialog.name_hint' => 'e.g., Main Kitchen, Front Desk', + 'client_hubs.add_hub_dialog.location_label' => 'Location Name', + 'client_hubs.add_hub_dialog.location_hint' => 'e.g., Downtown Restaurant', + 'client_hubs.add_hub_dialog.address_label' => 'Address', + 'client_hubs.add_hub_dialog.address_hint' => 'Full address', + 'client_hubs.add_hub_dialog.create_button' => 'Create Hub', + 'client_hubs.nfc_dialog.title' => 'Identify NFC Tag', + 'client_hubs.nfc_dialog.instruction' => 'Tap your phone to the NFC tag to identify it', + 'client_hubs.nfc_dialog.scan_button' => 'Scan NFC Tag', + 'client_hubs.nfc_dialog.tag_identified' => 'Tag Identified', + 'client_hubs.nfc_dialog.assign_button' => 'Assign Tag', _ => null, }; } diff --git a/apps/packages/core_localization/lib/src/l10n/strings_es.g.dart b/apps/packages/core_localization/lib/src/l10n/strings_es.g.dart index 7d2dd850..83958bd5 100644 --- a/apps/packages/core_localization/lib/src/l10n/strings_es.g.dart +++ b/apps/packages/core_localization/lib/src/l10n/strings_es.g.dart @@ -43,6 +43,7 @@ class TranslationsEs with BaseTranslations implements T @override late final _TranslationsClientAuthenticationEs client_authentication = _TranslationsClientAuthenticationEs._(_root); @override late final _TranslationsClientHomeEs client_home = _TranslationsClientHomeEs._(_root); @override late final _TranslationsClientSettingsEs client_settings = _TranslationsClientSettingsEs._(_root); + @override late final _TranslationsClientHubsEs client_hubs = _TranslationsClientHubsEs._(_root); } // Path: common @@ -121,6 +122,23 @@ class _TranslationsClientSettingsEs implements TranslationsClientSettingsEn { @override late final _TranslationsClientSettingsProfileEs profile = _TranslationsClientSettingsProfileEs._(_root); } +// Path: client_hubs +class _TranslationsClientHubsEs implements TranslationsClientHubsEn { + _TranslationsClientHubsEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Hubs'; + @override String get subtitle => 'Gestionar ubicaciones de marcaje'; + @override String get add_hub => 'Añadir Hub'; + @override late final _TranslationsClientHubsEmptyStateEs empty_state = _TranslationsClientHubsEmptyStateEs._(_root); + @override late final _TranslationsClientHubsAboutHubsEs about_hubs = _TranslationsClientHubsAboutHubsEs._(_root); + @override late final _TranslationsClientHubsHubCardEs hub_card = _TranslationsClientHubsHubCardEs._(_root); + @override late final _TranslationsClientHubsAddHubDialogEs add_hub_dialog = _TranslationsClientHubsAddHubDialogEs._(_root); + @override late final _TranslationsClientHubsNfcDialogEs nfc_dialog = _TranslationsClientHubsNfcDialogEs._(_root); +} + // Path: staff_authentication.get_started_page class _TranslationsStaffAuthenticationGetStartedPageEs implements TranslationsStaffAuthenticationGetStartedPageEn { _TranslationsStaffAuthenticationGetStartedPageEs._(this._root); @@ -360,6 +378,70 @@ class _TranslationsClientSettingsProfileEs implements TranslationsClientSettings @override String get billing_payments => 'Facturación y Pagos'; } +// Path: client_hubs.empty_state +class _TranslationsClientHubsEmptyStateEs implements TranslationsClientHubsEmptyStateEn { + _TranslationsClientHubsEmptyStateEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'No hay hubs aún'; + @override String get description => 'Crea estaciones de marcaje para tus ubicaciones'; + @override String get button => 'Añade tu primer Hub'; +} + +// Path: client_hubs.about_hubs +class _TranslationsClientHubsAboutHubsEs implements TranslationsClientHubsAboutHubsEn { + _TranslationsClientHubsAboutHubsEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Sobre los Hubs'; + @override String get description => 'Los Hubs son estaciones de marcaje en tus ubicaciones. Asigna etiquetas NFC a cada hub para que los trabajadores puedan marcar entrada/salida rápidamente usando sus teléfonos.'; +} + +// Path: client_hubs.hub_card +class _TranslationsClientHubsHubCardEs implements TranslationsClientHubsHubCardEn { + _TranslationsClientHubsHubCardEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String tag_label({required Object id}) => 'Etiqueta: ${id}'; +} + +// Path: client_hubs.add_hub_dialog +class _TranslationsClientHubsAddHubDialogEs implements TranslationsClientHubsAddHubDialogEn { + _TranslationsClientHubsAddHubDialogEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Añadir Nuevo Hub'; + @override String get name_label => 'Nombre del Hub *'; + @override String get name_hint => 'ej., Cocina Principal, Recepción'; + @override String get location_label => 'Nombre de la Ubicación'; + @override String get location_hint => 'ej., Restaurante Centro'; + @override String get address_label => 'Dirección'; + @override String get address_hint => 'Dirección completa'; + @override String get create_button => 'Crear Hub'; +} + +// Path: client_hubs.nfc_dialog +class _TranslationsClientHubsNfcDialogEs implements TranslationsClientHubsNfcDialogEn { + _TranslationsClientHubsNfcDialogEs._(this._root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Identificar Etiqueta NFC'; + @override String get instruction => 'Acerque su teléfono a la etiqueta NFC para identificarla'; + @override String get scan_button => 'Escanear Etiqueta NFC'; + @override String get tag_identified => 'Etiqueta Identificada'; + @override String get assign_button => 'Asignar Etiqueta'; +} + // Path: staff_authentication.profile_setup_page.steps class _TranslationsStaffAuthenticationProfileSetupPageStepsEs implements TranslationsStaffAuthenticationProfileSetupPageStepsEn { _TranslationsStaffAuthenticationProfileSetupPageStepsEs._(this._root); @@ -605,6 +687,28 @@ extension on TranslationsEs { 'client_settings.profile.quick_links' => 'Enlaces rápidos', 'client_settings.profile.clock_in_hubs' => 'Hubs de Marcaje', 'client_settings.profile.billing_payments' => 'Facturación y Pagos', + 'client_hubs.title' => 'Hubs', + 'client_hubs.subtitle' => 'Gestionar ubicaciones de marcaje', + 'client_hubs.add_hub' => 'Añadir Hub', + 'client_hubs.empty_state.title' => 'No hay hubs aún', + 'client_hubs.empty_state.description' => 'Crea estaciones de marcaje para tus ubicaciones', + 'client_hubs.empty_state.button' => 'Añade tu primer Hub', + 'client_hubs.about_hubs.title' => 'Sobre los Hubs', + 'client_hubs.about_hubs.description' => 'Los Hubs son estaciones de marcaje en tus ubicaciones. Asigna etiquetas NFC a cada hub para que los trabajadores puedan marcar entrada/salida rápidamente usando sus teléfonos.', + 'client_hubs.hub_card.tag_label' => ({required Object id}) => 'Etiqueta: ${id}', + 'client_hubs.add_hub_dialog.title' => 'Añadir Nuevo Hub', + 'client_hubs.add_hub_dialog.name_label' => 'Nombre del Hub *', + 'client_hubs.add_hub_dialog.name_hint' => 'ej., Cocina Principal, Recepción', + 'client_hubs.add_hub_dialog.location_label' => 'Nombre de la Ubicación', + 'client_hubs.add_hub_dialog.location_hint' => 'ej., Restaurante Centro', + 'client_hubs.add_hub_dialog.address_label' => 'Dirección', + 'client_hubs.add_hub_dialog.address_hint' => 'Dirección completa', + 'client_hubs.add_hub_dialog.create_button' => 'Crear Hub', + 'client_hubs.nfc_dialog.title' => 'Identificar Etiqueta NFC', + 'client_hubs.nfc_dialog.instruction' => 'Acerque su teléfono a la etiqueta NFC para identificarla', + 'client_hubs.nfc_dialog.scan_button' => 'Escanear Etiqueta NFC', + 'client_hubs.nfc_dialog.tag_identified' => 'Etiqueta Identificada', + 'client_hubs.nfc_dialog.assign_button' => 'Asignar Etiqueta', _ => null, }; } diff --git a/apps/packages/data_connect/lib/src/mocks/business_repository_mock.dart b/apps/packages/data_connect/lib/src/mocks/business_repository_mock.dart index 40d2ca9d..3895c0b6 100644 --- a/apps/packages/data_connect/lib/src/mocks/business_repository_mock.dart +++ b/apps/packages/data_connect/lib/src/mocks/business_repository_mock.dart @@ -25,4 +25,30 @@ class BusinessRepositoryMock { ), ]; } -} \ No newline at end of file + + Future createHub({ + required String businessId, + required String name, + required String address, + }) async { + await Future.delayed(const Duration(milliseconds: 500)); + return Hub( + id: 'hub_${DateTime.now().millisecondsSinceEpoch}', + businessId: businessId, + name: name, + address: address, + status: HubStatus.active, + ); + } + + Future deleteHub(String id) async { + await Future.delayed(const Duration(milliseconds: 300)); + } + + Future assignNfcTag({ + required String hubId, + required String nfcTagId, + }) async { + await Future.delayed(const Duration(milliseconds: 500)); + } +} diff --git a/apps/packages/domain/lib/src/entities/business/hub.dart b/apps/packages/domain/lib/src/entities/business/hub.dart index ac5a46c7..400d3bfe 100644 --- a/apps/packages/domain/lib/src/entities/business/hub.dart +++ b/apps/packages/domain/lib/src/entities/business/hub.dart @@ -26,6 +26,9 @@ class Hub extends Equatable { /// Physical address of this hub. final String address; + /// Unique identifier of the NFC tag assigned to this hub. + final String? nfcTagId; + /// Operational status. final HubStatus status; @@ -34,9 +37,10 @@ class Hub extends Equatable { required this.businessId, required this.name, required this.address, + this.nfcTagId, required this.status, }); @override - List get props => [id, businessId, name, address, status]; -} \ No newline at end of file + List get props => [id, businessId, name, address, nfcTagId, status]; +} diff --git a/apps/packages/features/client/hubs/lib/client_hubs.dart b/apps/packages/features/client/hubs/lib/client_hubs.dart new file mode 100644 index 00000000..cf0828b5 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/client_hubs.dart @@ -0,0 +1,49 @@ +library client_hubs; + +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; +import 'src/data/repositories_impl/hub_repository_impl.dart'; +import 'src/domain/repositories/hub_repository_interface.dart'; +import 'src/domain/usecases/assign_nfc_tag_usecase.dart'; +import 'src/domain/usecases/create_hub_usecase.dart'; +import 'src/domain/usecases/delete_hub_usecase.dart'; +import 'src/domain/usecases/get_hubs_usecase.dart'; +import 'src/presentation/blocs/client_hubs_bloc.dart'; +import 'src/presentation/pages/client_hubs_page.dart'; + +export 'src/presentation/pages/client_hubs_page.dart'; + +/// A [Module] for the client hubs feature. +class ClientHubsModule extends Module { + @override + List get imports => [DataConnectModule()]; + + @override + void binds(Injector i) { + // Repositories + i.addLazySingleton( + () => HubRepositoryImpl(mock: i.get()), + ); + + // UseCases + i.addLazySingleton(GetHubsUseCase.new); + i.addLazySingleton(CreateHubUseCase.new); + i.addLazySingleton(DeleteHubUseCase.new); + i.addLazySingleton(AssignNfcTagUseCase.new); + + // BLoCs + i.add( + () => ClientHubsBloc( + getHubsUseCase: i.get(), + createHubUseCase: i.get(), + deleteHubUseCase: i.get(), + assignNfcTagUseCase: i.get(), + ), + ); + } + + @override + void routes(RouteManager r) { + r.child('/', child: (_) => const ClientHubsPage()); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart new file mode 100644 index 00000000..6224816a --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -0,0 +1,37 @@ +import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../../domain/repositories/hub_repository_interface.dart'; + +/// Implementation of [HubRepositoryInterface]. +/// +/// This implementation delegates data access to the [BusinessRepositoryMock] +/// from the `data_connect` package. +class HubRepositoryImpl implements HubRepositoryInterface { + /// The business repository mock from data connect. + final BusinessRepositoryMock mock; + + /// Creates a [HubRepositoryImpl] with the required [mock]. + HubRepositoryImpl({required this.mock}); + + @override + Future> getHubs() { + // In a real app, we would get the business ID from a session or state. + // For this prototype/mock, we use a hardcoded value. + return mock.getHubs('biz_1'); + } + + @override + Future createHub({required String name, required String address}) { + return mock.createHub(businessId: 'biz_1', name: name, address: address); + } + + @override + Future deleteHub(String id) { + return mock.deleteHub(id); + } + + @override + Future assignNfcTag({required String hubId, required String nfcTagId}) { + return mock.assignNfcTag(hubId: hubId, nfcTagId: nfcTagId); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/domain/arguments/hub_arguments.dart b/apps/packages/features/client/hubs/lib/src/domain/arguments/hub_arguments.dart new file mode 100644 index 00000000..d85c16dc --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/domain/arguments/hub_arguments.dart @@ -0,0 +1,33 @@ +import 'package:krow_core/core.dart'; + +/// Arguments for creating a new hub. +class CreateHubArguments extends UseCaseArgument { + final String name; + final String address; + + const CreateHubArguments({required this.name, required this.address}); + + @override + List get props => [name, address]; +} + +/// Arguments for assigning an NFC tag to a hub. +class AssignNfcTagArguments extends UseCaseArgument { + final String hubId; + final String nfcTagId; + + const AssignNfcTagArguments({required this.hubId, required this.nfcTagId}); + + @override + List get props => [hubId, nfcTagId]; +} + +/// Arguments for deleting a hub. +class DeleteHubArguments extends UseCaseArgument { + final String hubId; + + const DeleteHubArguments({required this.hubId}); + + @override + List get props => [hubId]; +} diff --git a/apps/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart b/apps/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart new file mode 100644 index 00000000..8c7b7d3c --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart @@ -0,0 +1,19 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Interface for the Hub repository. +/// +/// This repository handles hub-related operations such as +/// fetching, creating, deleting hubs and assigning NFC tags. +abstract interface class HubRepositoryInterface { + /// Fetches the list of hubs for the current client. + Future> getHubs(); + + /// Creates a new hub. + Future createHub({required String name, required String address}); + + /// Deletes a hub by its [id]. + Future deleteHub(String id); + + /// Assigns an NFC tag to a hub. + Future assignNfcTag({required String hubId, required String nfcTagId}); +} diff --git a/apps/packages/features/client/hubs/lib/src/domain/usecases/assign_nfc_tag_usecase.dart b/apps/packages/features/client/hubs/lib/src/domain/usecases/assign_nfc_tag_usecase.dart new file mode 100644 index 00000000..4b3ce693 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/domain/usecases/assign_nfc_tag_usecase.dart @@ -0,0 +1,18 @@ +import 'package:krow_core/core.dart'; +import '../arguments/hub_arguments.dart'; +import '../repositories/hub_repository_interface.dart'; + +/// Use case for assigning an NFC tag to a hub. +class AssignNfcTagUseCase implements UseCase { + final HubRepositoryInterface _repository; + + AssignNfcTagUseCase(this._repository); + + @override + Future call(AssignNfcTagArguments arguments) { + return _repository.assignNfcTag( + hubId: arguments.hubId, + nfcTagId: arguments.nfcTagId, + ); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart b/apps/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart new file mode 100644 index 00000000..f6f80baa --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart @@ -0,0 +1,19 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../arguments/hub_arguments.dart'; +import '../repositories/hub_repository_interface.dart'; + +/// Use case for creating a new hub. +class CreateHubUseCase implements UseCase { + final HubRepositoryInterface _repository; + + CreateHubUseCase(this._repository); + + @override + Future call(CreateHubArguments arguments) { + return _repository.createHub( + name: arguments.name, + address: arguments.address, + ); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/domain/usecases/delete_hub_usecase.dart b/apps/packages/features/client/hubs/lib/src/domain/usecases/delete_hub_usecase.dart new file mode 100644 index 00000000..5b035569 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/domain/usecases/delete_hub_usecase.dart @@ -0,0 +1,15 @@ +import 'package:krow_core/core.dart'; +import '../arguments/hub_arguments.dart'; +import '../repositories/hub_repository_interface.dart'; + +/// Use case for deleting a hub. +class DeleteHubUseCase implements UseCase { + final HubRepositoryInterface _repository; + + DeleteHubUseCase(this._repository); + + @override + Future call(DeleteHubArguments arguments) { + return _repository.deleteHub(arguments.hubId); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/domain/usecases/get_hubs_usecase.dart b/apps/packages/features/client/hubs/lib/src/domain/usecases/get_hubs_usecase.dart new file mode 100644 index 00000000..cb6c937c --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/domain/usecases/get_hubs_usecase.dart @@ -0,0 +1,15 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/hub_repository_interface.dart'; + +/// Use case for fetching the list of hubs. +class GetHubsUseCase implements NoInputUseCase> { + final HubRepositoryInterface _repository; + + GetHubsUseCase(this._repository); + + @override + Future> call() { + return _repository.getHubs(); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart b/apps/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart new file mode 100644 index 00000000..623c7b30 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart @@ -0,0 +1,154 @@ +import 'package:bloc/bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import '../../domain/arguments/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 'client_hubs_event.dart'; +import 'client_hubs_state.dart'; + +/// BLoC responsible for managing client hubs. +class ClientHubsBloc extends Bloc + implements Disposable { + final GetHubsUseCase _getHubsUseCase; + final CreateHubUseCase _createHubUseCase; + final DeleteHubUseCase _deleteHubUseCase; + final AssignNfcTagUseCase _assignNfcTagUseCase; + + ClientHubsBloc({ + required GetHubsUseCase getHubsUseCase, + required CreateHubUseCase createHubUseCase, + required DeleteHubUseCase deleteHubUseCase, + required AssignNfcTagUseCase assignNfcTagUseCase, + }) : _getHubsUseCase = getHubsUseCase, + _createHubUseCase = createHubUseCase, + _deleteHubUseCase = deleteHubUseCase, + _assignNfcTagUseCase = assignNfcTagUseCase, + super(const ClientHubsState()) { + on(_onFetched); + on(_onAddRequested); + on(_onDeleteRequested); + on(_onNfcTagAssignRequested); + on(_onMessageCleared); + } + + Future _onFetched( + ClientHubsFetched event, + Emitter emit, + ) async { + emit(state.copyWith(status: ClientHubsStatus.loading)); + try { + final hubs = await _getHubsUseCase(); + emit(state.copyWith(status: ClientHubsStatus.success, hubs: hubs)); + } catch (e) { + emit( + state.copyWith( + status: ClientHubsStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onAddRequested( + ClientHubsAddRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); + try { + await _createHubUseCase( + CreateHubArguments(name: event.name, address: event.address), + ); + final hubs = await _getHubsUseCase(); + emit( + state.copyWith( + status: ClientHubsStatus.actionSuccess, + hubs: hubs, + successMessage: 'Hub created successfully', + ), + ); + } catch (e) { + emit( + state.copyWith( + status: ClientHubsStatus.actionFailure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onDeleteRequested( + ClientHubsDeleteRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); + try { + await _deleteHubUseCase(DeleteHubArguments(hubId: event.hubId)); + final hubs = await _getHubsUseCase(); + emit( + state.copyWith( + status: ClientHubsStatus.actionSuccess, + hubs: hubs, + successMessage: 'Hub deleted successfully', + ), + ); + } catch (e) { + emit( + state.copyWith( + status: ClientHubsStatus.actionFailure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onNfcTagAssignRequested( + ClientHubsNfcTagAssignRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); + try { + await _assignNfcTagUseCase( + AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId), + ); + final hubs = await _getHubsUseCase(); + emit( + state.copyWith( + status: ClientHubsStatus.actionSuccess, + hubs: hubs, + successMessage: 'NFC tag assigned successfully', + ), + ); + } catch (e) { + emit( + state.copyWith( + status: ClientHubsStatus.actionFailure, + errorMessage: e.toString(), + ), + ); + } + } + + void _onMessageCleared( + ClientHubsMessageCleared event, + Emitter emit, + ) { + emit( + state.copyWith( + errorMessage: null, + successMessage: null, + status: + state.status == ClientHubsStatus.actionSuccess || + state.status == ClientHubsStatus.actionFailure + ? ClientHubsStatus.success + : state.status, + ), + ); + } + + @override + void dispose() { + close(); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart b/apps/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart new file mode 100644 index 00000000..1007e395 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart @@ -0,0 +1,54 @@ +import 'package:equatable/equatable.dart'; + +/// Base class for all client hubs events. +abstract class ClientHubsEvent extends Equatable { + const ClientHubsEvent(); + + @override + List get props => []; +} + +/// Event triggered to fetch the list of hubs. +class ClientHubsFetched extends ClientHubsEvent { + const ClientHubsFetched(); +} + +/// Event triggered to add a new hub. +class ClientHubsAddRequested extends ClientHubsEvent { + final String name; + final String address; + + const ClientHubsAddRequested({required this.name, required this.address}); + + @override + List get props => [name, address]; +} + +/// Event triggered to delete a hub. +class ClientHubsDeleteRequested extends ClientHubsEvent { + final String hubId; + + const ClientHubsDeleteRequested(this.hubId); + + @override + List get props => [hubId]; +} + +/// Event triggered to assign an NFC tag to a hub. +class ClientHubsNfcTagAssignRequested extends ClientHubsEvent { + final String hubId; + final String nfcTagId; + + const ClientHubsNfcTagAssignRequested({ + required this.hubId, + required this.nfcTagId, + }); + + @override + List get props => [hubId, nfcTagId]; +} + +/// Event triggered to clear any error or success messages. +class ClientHubsMessageCleared extends ClientHubsEvent { + const ClientHubsMessageCleared(); +} diff --git a/apps/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart b/apps/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart new file mode 100644 index 00000000..90d3ee71 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Enum representing the status of the client hubs state. +enum ClientHubsStatus { + initial, + loading, + success, + failure, + actionInProgress, + actionSuccess, + actionFailure, +} + +/// State class for the ClientHubs BLoC. +class ClientHubsState extends Equatable { + final ClientHubsStatus status; + final List hubs; + final String? errorMessage; + final String? successMessage; + + const ClientHubsState({ + this.status = ClientHubsStatus.initial, + this.hubs = const [], + this.errorMessage, + this.successMessage, + }); + + ClientHubsState copyWith({ + ClientHubsStatus? status, + List? hubs, + String? errorMessage, + String? successMessage, + }) { + return ClientHubsState( + status: status ?? this.status, + hubs: hubs ?? this.hubs, + errorMessage: errorMessage ?? this.errorMessage, + successMessage: successMessage ?? this.successMessage, + ); + } + + @override + List get props => [status, hubs, errorMessage, successMessage]; +} diff --git a/apps/packages/features/client/hubs/lib/src/presentation/navigation/client_hubs_navigator.dart b/apps/packages/features/client/hubs/lib/src/presentation/navigation/client_hubs_navigator.dart new file mode 100644 index 00000000..cb534c0d --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/presentation/navigation/client_hubs_navigator.dart @@ -0,0 +1,9 @@ +import 'package:flutter_modular/flutter_modular.dart'; + +/// Extension on [IModularNavigator] to provide typed navigation for client hubs. +extension ClientHubsNavigator on IModularNavigator { + /// Navigates to the client hubs page. + Future pushClientHubs() async { + await pushNamed('/client/hubs/'); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart b/apps/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart new file mode 100644 index 00000000..590d6280 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart @@ -0,0 +1,202 @@ +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:lucide_icons/lucide_icons.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:core_localization/core_localization.dart'; +import '../blocs/client_hubs_bloc.dart'; +import '../blocs/client_hubs_event.dart'; +import '../blocs/client_hubs_state.dart'; +import '../widgets/add_hub_dialog.dart'; +import '../widgets/hub_card.dart'; +import '../widgets/hub_empty_state.dart'; +import '../widgets/hub_info_card.dart'; +import '../widgets/identify_nfc_dialog.dart'; + +/// The main page for the client hubs feature. +class ClientHubsPage extends StatefulWidget { + /// Creates a [ClientHubsPage]. + const ClientHubsPage({super.key}); + + @override + State createState() => _ClientHubsPageState(); +} + +class _ClientHubsPageState extends State { + bool _showAddHub = false; + Hub? _hubToIdentify; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + Modular.get()..add(const ClientHubsFetched()), + child: BlocConsumer( + listener: (context, state) { + if (state.errorMessage != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(state.errorMessage!))); + BlocProvider.of( + context, + ).add(const ClientHubsMessageCleared()); + } + if (state.successMessage != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(state.successMessage!))); + BlocProvider.of( + context, + ).add(const ClientHubsMessageCleared()); + } + }, + builder: (context, state) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), // slate-50 + body: Stack( + children: [ + CustomScrollView( + slivers: [ + _buildAppBar(context), + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 100), + sliver: SliverList( + delegate: SliverChildListDelegate([ + if (state.status == ClientHubsStatus.loading) + const Center(child: CircularProgressIndicator()) + else if (state.hubs.isEmpty) + HubEmptyState( + onAddPressed: () => + setState(() => _showAddHub = true), + ) + else ...[ + ...state.hubs.map( + (hub) => HubCard( + hub: hub, + onNfcPressed: () => + setState(() => _hubToIdentify = hub), + onDeletePressed: () => + BlocProvider.of( + context, + ).add(ClientHubsDeleteRequested(hub.id)), + ), + ), + ], + const SizedBox(height: 20), + const HubInfoCard(), + ]), + ), + ), + ], + ), + if (_showAddHub) + AddHubDialog( + onCreate: (name, address) { + BlocProvider.of(context).add( + ClientHubsAddRequested(name: name, address: address), + ); + setState(() => _showAddHub = false); + }, + onCancel: () => setState(() => _showAddHub = false), + ), + if (_hubToIdentify != null) + IdentifyNfcDialog( + hub: _hubToIdentify!, + onAssign: (tagId) { + BlocProvider.of(context).add( + ClientHubsNfcTagAssignRequested( + hubId: _hubToIdentify!.id, + nfcTagId: tagId, + ), + ); + setState(() => _hubToIdentify = null); + }, + onCancel: () => setState(() => _hubToIdentify = null), + ), + if (state.status == ClientHubsStatus.actionInProgress) + Container( + color: Colors.black.withOpacity(0.1), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildAppBar(BuildContext context) { + return SliverAppBar( + backgroundColor: const Color(0xFF121826), + automaticallyImplyLeading: false, + expandedHeight: 140, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF121826), Color(0xFF1E293B)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + padding: const EdgeInsets.fromLTRB(20, 48, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.arrowLeft, + color: Colors.white, + size: 20, + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.client_hubs.title, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + Text( + t.client_hubs.subtitle, + style: const TextStyle( + color: Color(0xFFCBD5E1), // slate-300 + fontSize: 14, + ), + ), + ], + ), + UiButton.primary( + onPressed: () => setState(() => _showAddHub = true), + text: t.client_hubs.add_hub, + leadingIcon: LucideIcons.plus, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart b/apps/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart new file mode 100644 index 00000000..c618c650 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart @@ -0,0 +1,154 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:core_localization/core_localization.dart'; + +/// A dialog for adding a new hub. +class AddHubDialog extends StatefulWidget { + /// Callback when the "Create Hub" button is pressed. + final Function(String name, String address) onCreate; + + /// Callback when the dialog is cancelled. + final VoidCallback onCancel; + + /// Creates an [AddHubDialog]. + const AddHubDialog({ + required this.onCreate, + required this.onCancel, + super.key, + }); + + @override + State createState() => _AddHubDialogState(); +} + +class _AddHubDialogState extends State { + late final TextEditingController _nameController; + late final TextEditingController _addressController; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _addressController = TextEditingController(); + } + + @override + void dispose() { + _nameController.dispose(); + _addressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withOpacity(0.5), + child: Center( + child: SingleChildScrollView( + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 20), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.client_hubs.add_hub_dialog.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 24), + _buildFieldLabel(t.client_hubs.add_hub_dialog.name_label), + TextField( + controller: _nameController, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.name_hint, + ), + ), + const SizedBox(height: 16), + _buildFieldLabel(t.client_hubs.add_hub_dialog.address_label), + TextField( + controller: _addressController, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.address_hint, + ), + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: UiButton.secondary( + onPressed: widget.onCancel, + text: t.common.cancel, + ), + ), + const SizedBox(width: 12), + Expanded( + child: UiButton.primary( + onPressed: () { + if (_nameController.text.isNotEmpty) { + widget.onCreate( + _nameController.text, + _addressController.text, + ); + } + }, + text: t.client_hubs.add_hub_dialog.create_button, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildFieldLabel(String label) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF0F172A), + ), + ), + ); + } + + InputDecoration _buildInputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 14), + filled: true, + fillColor: const Color(0xFFF8FAFC), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF2563EB), width: 2), + ), + ); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart b/apps/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart new file mode 100644 index 00000000..e8d9673b --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:core_localization/core_localization.dart'; + +/// A card displaying information about a single hub. +class HubCard extends StatelessWidget { + /// The hub to display. + final Hub hub; + + /// Callback when the NFC button is pressed. + final VoidCallback onNfcPressed; + + /// Callback when the delete button is pressed. + final VoidCallback onDeletePressed; + + /// Creates a [HubCard]. + const HubCard({ + required this.hub, + required this.onNfcPressed, + required this.onDeletePressed, + super.key, + }); + + @override + Widget build(BuildContext context) { + final bool hasNfc = hub.nfcTagId != null; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), // blue-50 + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + hasNfc ? LucideIcons.checkCircle : LucideIcons.nfc, + color: hasNfc + ? const Color(0xFF16A34A) + : const Color(0xFF94A3B8), // green-600 or slate-400 + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hub.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Color(0xFF0F172A), + ), + ), + if (hub.address.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + const Icon( + LucideIcons.mapPin, + size: 12, + color: Color(0xFF94A3B8), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + hub.address, + style: const TextStyle( + color: Color(0xFF64748B), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (hasNfc) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!), + style: const TextStyle( + color: Color(0xFF16A34A), + fontSize: 12, + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + Row( + children: [ + IconButton( + onPressed: onNfcPressed, + icon: const Icon( + LucideIcons.nfc, + color: Color(0xFF2563EB), + size: 20, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashRadius: 20, + ), + const SizedBox(width: 8), + IconButton( + onPressed: onDeletePressed, + icon: const Icon( + LucideIcons.trash2, + color: Color(0xFFDC2626), + size: 20, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashRadius: 20, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/presentation/widgets/hub_empty_state.dart b/apps/packages/features/client/hubs/lib/src/presentation/widgets/hub_empty_state.dart new file mode 100644 index 00000000..3836bdb5 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/presentation/widgets/hub_empty_state.dart @@ -0,0 +1,69 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:core_localization/core_localization.dart'; + +/// Widget displayed when there are no hubs. +class HubEmptyState extends StatelessWidget { + /// Callback when the add button is pressed. + final VoidCallback onAddPressed; + + /// Creates a [HubEmptyState]. + const HubEmptyState({required this.onAddPressed, super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + color: Color(0xFFF1F5F9), // slate-100 + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.nfc, + size: 32, + color: Color(0xFF94A3B8), + ), + ), + const SizedBox(height: 16), + Text( + t.client_hubs.empty_state.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 8), + Text( + t.client_hubs.empty_state.description, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF64748B), fontSize: 14), + ), + const SizedBox(height: 24), + UiButton.primary( + onPressed: onAddPressed, + text: t.client_hubs.empty_state.button, + leadingIcon: LucideIcons.plus, + ), + ], + ), + ); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart b/apps/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart new file mode 100644 index 00000000..c53f72a1 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:core_localization/core_localization.dart'; + +/// A card with information about how hubs work. +class HubInfoCard extends StatelessWidget { + /// Creates a [HubInfoCard]. + const HubInfoCard({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), // blue-50 + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(LucideIcons.nfc, size: 20, color: Color(0xFF2563EB)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.client_hubs.about_hubs.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 4), + Text( + t.client_hubs.about_hubs.description, + style: const TextStyle( + color: Color(0xFF334155), + fontSize: 12, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/packages/features/client/hubs/lib/src/presentation/widgets/identify_nfc_dialog.dart b/apps/packages/features/client/hubs/lib/src/presentation/widgets/identify_nfc_dialog.dart new file mode 100644 index 00000000..ed5362f5 --- /dev/null +++ b/apps/packages/features/client/hubs/lib/src/presentation/widgets/identify_nfc_dialog.dart @@ -0,0 +1,186 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:core_localization/core_localization.dart'; + +/// A dialog for identifying and assigning an NFC tag to a hub. +class IdentifyNfcDialog extends StatefulWidget { + /// The hub to assign the tag to. + final Hub hub; + + /// Callback when a tag is assigned. + final Function(String nfcTagId) onAssign; + + /// Callback when the dialog is cancelled. + final VoidCallback onCancel; + + /// Creates an [IdentifyNfcDialog]. + const IdentifyNfcDialog({ + required this.hub, + required this.onAssign, + required this.onCancel, + super.key, + }); + + @override + State createState() => _IdentifyNfcDialogState(); +} + +class _IdentifyNfcDialogState extends State { + String? _nfcTagId; + + void _simulateNFCScan() { + setState(() { + _nfcTagId = + 'NFC-${DateTime.now().millisecondsSinceEpoch.toString().substring(8).toUpperCase()}'; + }); + } + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withOpacity(0.5), + child: Center( + child: SingleChildScrollView( + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 20), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.client_hubs.nfc_dialog.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 32), + Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + color: Color(0xFFEFF6FF), // blue-50 + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.nfc, + size: 40, + color: Color(0xFF2563EB), + ), + ), + const SizedBox(height: 16), + Text( + widget.hub.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 8), + Text( + t.client_hubs.nfc_dialog.instruction, + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF64748B), + fontSize: 14, + ), + ), + const SizedBox(height: 24), + UiButton.secondary( + onPressed: _simulateNFCScan, + text: t.client_hubs.nfc_dialog.scan_button, + leadingIcon: LucideIcons.nfc, + ), + if (_nfcTagId != null) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF0FDF4), // green-50 + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.checkCircle, + size: 20, + color: Color(0xFF16A34A), + ), + const SizedBox(width: 8), + Text( + t.client_hubs.nfc_dialog.tag_identified, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Color(0xFF0F172A), + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFDCE8E0)), + ), + child: Text( + _nfcTagId!, + style: const TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + fontSize: 12, + color: Color(0xFF334155), + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: UiButton.secondary( + onPressed: widget.onCancel, + text: t.common.cancel, + ), + ), + const SizedBox(width: 12), + Expanded( + child: UiButton.primary( + onPressed: _nfcTagId != null + ? () => widget.onAssign(_nfcTagId!) + : null, + text: t.client_hubs.nfc_dialog.assign_button, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/packages/features/client/hubs/pubspec.yaml b/apps/packages/features/client/hubs/pubspec.yaml new file mode 100644 index 00000000..f625494a --- /dev/null +++ b/apps/packages/features/client/hubs/pubspec.yaml @@ -0,0 +1,37 @@ +name: client_hubs +description: "Client hubs management feature for the KROW platform." +version: 0.0.1 +publish_to: none +resolution: workspace + +environment: + sdk: '>=3.10.0 <4.0.0' + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_bloc: ^8.1.0 + flutter_modular: ^6.3.2 + equatable: ^2.0.5 + lucide_icons: ^0.257.0 + + # KROW Packages + krow_core: + path: ../../../core + krow_domain: + path: ../../../domain + krow_data_connect: + path: ../../../data_connect + design_system: + path: ../../../design_system + core_localization: + path: ../../../core_localization + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index d9c97690..6afd2573 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -13,6 +13,7 @@ workspace: - packages/features/client/authentication - packages/features/client/home - packages/features/client/settings + - packages/features/client/hubs - apps/staff - apps/client - apps/design_system_viewer