feat: refactor hub management to use dedicated pages for adding, editing, and viewing hub details.

This commit is contained in:
Achintha Isuru
2026-02-24 13:46:39 -05:00
parent ca754b70a0
commit 7591e71c3d
17 changed files with 768 additions and 642 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

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

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

@@ -3,57 +3,38 @@ 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/assign_nfc_tag_arguments.dart';
import '../../domain/arguments/create_hub_arguments.dart';
import '../../domain/arguments/delete_hub_arguments.dart'; import '../../domain/arguments/delete_hub_arguments.dart';
import '../../domain/usecases/assign_nfc_tag_usecase.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/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, deleting, and assigning tags to hubs.
class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState> class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
with BlocErrorHandler<ClientHubsState> with BlocErrorHandler<ClientHubsState>
implements Disposable { implements Disposable {
ClientHubsBloc({ ClientHubsBloc({
required GetHubsUseCase getHubsUseCase, required GetHubsUseCase getHubsUseCase,
required CreateHubUseCase createHubUseCase,
required DeleteHubUseCase deleteHubUseCase, required DeleteHubUseCase deleteHubUseCase,
required AssignNfcTagUseCase assignNfcTagUseCase, required AssignNfcTagUseCase assignNfcTagUseCase,
required UpdateHubUseCase updateHubUseCase,
}) : _getHubsUseCase = getHubsUseCase, }) : _getHubsUseCase = getHubsUseCase,
_createHubUseCase = createHubUseCase,
_deleteHubUseCase = deleteHubUseCase, _deleteHubUseCase = deleteHubUseCase,
_assignNfcTagUseCase = assignNfcTagUseCase, _assignNfcTagUseCase = assignNfcTagUseCase,
_updateHubUseCase = updateHubUseCase,
super(const ClientHubsState()) { super(const ClientHubsState()) {
on<ClientHubsFetched>(_onFetched); on<ClientHubsFetched>(_onFetched);
on<ClientHubsAddRequested>(_onAddRequested);
on<ClientHubsUpdateRequested>(_onUpdateRequested);
on<ClientHubsDeleteRequested>(_onDeleteRequested); on<ClientHubsDeleteRequested>(_onDeleteRequested);
on<ClientHubsNfcTagAssignRequested>(_onNfcTagAssignRequested); on<ClientHubsNfcTagAssignRequested>(_onNfcTagAssignRequested);
on<ClientHubsMessageCleared>(_onMessageCleared); on<ClientHubsMessageCleared>(_onMessageCleared);
on<ClientHubsAddDialogToggled>(_onAddDialogToggled);
on<ClientHubsIdentifyDialogToggled>(_onIdentifyDialogToggled); on<ClientHubsIdentifyDialogToggled>(_onIdentifyDialogToggled);
} }
final GetHubsUseCase _getHubsUseCase; final GetHubsUseCase _getHubsUseCase;
final CreateHubUseCase _createHubUseCase;
final DeleteHubUseCase _deleteHubUseCase; final DeleteHubUseCase _deleteHubUseCase;
final AssignNfcTagUseCase _assignNfcTagUseCase; final AssignNfcTagUseCase _assignNfcTagUseCase;
final UpdateHubUseCase _updateHubUseCase;
void _onAddDialogToggled(
ClientHubsAddDialogToggled event,
Emitter<ClientHubsState> emit,
) {
emit(state.copyWith(showAddHubDialog: event.visible));
}
void _onIdentifyDialogToggled( void _onIdentifyDialogToggled(
ClientHubsIdentifyDialogToggled event, ClientHubsIdentifyDialogToggled event,
@@ -75,7 +56,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,86 +66,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( Future<void> _onDeleteRequested(
ClientHubsDeleteRequested event, ClientHubsDeleteRequested event,
Emitter<ClientHubsState> emit, Emitter<ClientHubsState> emit,
@@ -174,8 +75,8 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
await _deleteHubUseCase(DeleteHubArguments(hubId: event.hubId)); await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.hubId));
final List<Hub> hubs = await _getHubsUseCase(); final List<Hub> hubs = await _getHubsUseCase.call();
emit( emit(
state.copyWith( state.copyWith(
status: ClientHubsStatus.actionSuccess, status: ClientHubsStatus.actionSuccess,
@@ -200,10 +101,10 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
await _assignNfcTagUseCase( await _assignNfcTagUseCase.call(
AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId), AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId),
); );
final List<Hub> hubs = await _getHubsUseCase(); final List<Hub> hubs = await _getHubsUseCase.call();
emit( emit(
state.copyWith( state.copyWith(
status: ClientHubsStatus.actionSuccess, status: ClientHubsStatus.actionSuccess,

View File

@@ -14,94 +14,8 @@ 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. /// Event triggered to delete a hub.
class ClientHubsDeleteRequested extends ClientHubsEvent { class ClientHubsDeleteRequested extends ClientHubsEvent {
const ClientHubsDeleteRequested(this.hubId); const ClientHubsDeleteRequested(this.hubId);
final String hubId; final String hubId;
@@ -111,7 +25,6 @@ class ClientHubsDeleteRequested extends ClientHubsEvent {
/// Event triggered to assign an NFC tag to a hub. /// Event triggered to assign an NFC tag to a hub.
class ClientHubsNfcTagAssignRequested extends ClientHubsEvent { class ClientHubsNfcTagAssignRequested extends ClientHubsEvent {
const ClientHubsNfcTagAssignRequested({ const ClientHubsNfcTagAssignRequested({
required this.hubId, required this.hubId,
required this.nfcTagId, required this.nfcTagId,
@@ -128,19 +41,8 @@ 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. /// Event triggered to toggle the visibility of the "Identify NFC" dialog.
class ClientHubsIdentifyDialogToggled extends ClientHubsEvent { class ClientHubsIdentifyDialogToggled extends ClientHubsEvent {
const ClientHubsIdentifyDialogToggled({this.hub}); const ClientHubsIdentifyDialogToggled({this.hub});
final Hub? hub; final Hub? hub;

View File

@@ -14,23 +14,19 @@ enum ClientHubsStatus {
/// 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, 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. /// The hub currently being identified/assigned an NFC tag.
/// If null, the identification dialog is closed. /// If null, the identification dialog is closed.
final Hub? hubToIdentify; final Hub? hubToIdentify;
@@ -40,7 +36,6 @@ class ClientHubsState extends Equatable {
List<Hub>? hubs, List<Hub>? hubs,
String? errorMessage, String? errorMessage,
String? successMessage, String? successMessage,
bool? showAddHubDialog,
Hub? hubToIdentify, Hub? hubToIdentify,
bool clearHubToIdentify = false, bool clearHubToIdentify = false,
bool clearErrorMessage = false, bool clearErrorMessage = false,
@@ -55,7 +50,6 @@ class ClientHubsState extends Equatable {
successMessage: clearSuccessMessage successMessage: clearSuccessMessage
? null ? null
: (successMessage ?? this.successMessage), : (successMessage ?? this.successMessage),
showAddHubDialog: showAddHubDialog ?? this.showAddHubDialog,
hubToIdentify: clearHubToIdentify hubToIdentify: clearHubToIdentify
? null ? null
: (hubToIdentify ?? this.hubToIdentify), : (hubToIdentify ?? this.hubToIdentify),
@@ -68,7 +62,6 @@ class ClientHubsState extends Equatable {
hubs, hubs,
errorMessage, errorMessage,
successMessage, successMessage,
showAddHubDialog,
hubToIdentify, 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,
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,
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,
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,
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,7 +8,7 @@ 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';
@@ -43,7 +43,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,9 +59,14 @@ class ClientHubsPage extends StatelessWidget {
return Scaffold( return Scaffold(
backgroundColor: UiColors.bgMenu, backgroundColor: UiColors.bgMenu,
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => BlocProvider.of<ClientHubsBloc>( onPressed: () async {
final bool? success = await Modular.to.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context, context,
).add(const ClientHubsAddDialogToggled(visible: true)), ).add(const ClientHubsFetched());
}
},
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
@@ -82,27 +88,37 @@ class ClientHubsPage extends StatelessWidget {
const Center(child: CircularProgressIndicator()) const Center(child: CircularProgressIndicator())
else if (state.hubs.isEmpty) else if (state.hubs.isEmpty)
HubEmptyState( HubEmptyState(
onAddPressed: () => onAddPressed: () async {
BlocProvider.of<ClientHubsBloc>(context).add( final bool? success = await Modular.to
const ClientHubsAddDialogToggled( .toEditHub();
visible: true, if (success == true && context.mounted) {
), BlocProvider.of<ClientHubsBloc>(
), context,
).add(const ClientHubsFetched());
}
},
) )
else ...<Widget>[ else ...<Widget>[
...state.hubs.map( ...state.hubs.map(
(Hub hub) => HubCard( (Hub hub) => HubCard(
hub: hub, hub: hub,
onTap: () async {
final bool? success = await Modular.to
.toHubDetails(hub);
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
onNfcPressed: () => onNfcPressed: () =>
BlocProvider.of<ClientHubsBloc>( BlocProvider.of<ClientHubsBloc>(
context, context,
).add( ).add(
ClientHubsIdentifyDialogToggled(hub: hub), ClientHubsIdentifyDialogToggled(hub: hub),
), ),
onDeletePressed: () => _confirmDeleteHub( onDeletePressed: () =>
context, _confirmDeleteHub(context, hub),
hub,
),
), ),
), ),
], ],
@@ -113,29 +129,7 @@ class ClientHubsPage extends StatelessWidget {
), ),
], ],
), ),
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) if (state.hubToIdentify != null)
IdentifyNfcDialog( IdentifyNfcDialog(
hub: state.hubToIdentify!, hub: state.hubToIdentify!,

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/hub_address_autocomplete.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,8 +32,8 @@ 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();
} }
@@ -64,9 +57,20 @@ 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(),
address: _addressController.text.trim(),
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
),
);
} else {
widget.bloc.add(
EditHubUpdateRequested(
id: widget.hub!.id,
name: _nameController.text.trim(), name: _nameController.text.trim(),
address: _addressController.text.trim(), address: _addressController.text.trim(),
placeId: _selectedPrediction?.placeId, placeId: _selectedPrediction?.placeId,
@@ -75,26 +79,28 @@ class _EditHubPageState extends State<EditHubPage> {
), ),
); );
} }
}
@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,10 +109,9 @@ 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,
@@ -114,17 +119,21 @@ class _EditHubPageState extends State<EditHubPage> {
backgroundColor: UiColors.foreground, backgroundColor: UiColors.foreground,
leading: IconButton( leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Modular.to.pop(),
), ),
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
t.client_hubs.edit_hub.title, widget.hub == null
? t.client_hubs.add_hub_dialog.title
: t.client_hubs.edit_hub.title,
style: UiTypography.headline3m.white, style: UiTypography.headline3m.white,
), ),
Text( Text(
t.client_hubs.edit_hub.subtitle, widget.hub == null
? t.client_hubs.add_hub_dialog.create_button
: t.client_hubs.edit_hub.subtitle,
style: UiTypography.footnote1r.copyWith( style: UiTypography.footnote1r.copyWith(
color: UiColors.white.withValues(alpha: 0.7), color: UiColors.white.withValues(alpha: 0.7),
), ),
@@ -176,7 +185,9 @@ class _EditHubPageState extends State<EditHubPage> {
// ── Save button ────────────────────────────────── // ── Save button ──────────────────────────────────
UiButton.primary( UiButton.primary(
onPressed: isSaving ? null : _onSave, onPressed: isSaving ? null : _onSave,
text: t.client_hubs.edit_hub.save_button, text: widget.hub == null
? t.client_hubs.add_hub_dialog.create_button
: t.client_hubs.edit_hub.save_button,
), ),
const SizedBox(height: 40), const SizedBox(height: 40),

View File

@@ -1,37 +1,64 @@
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';
/// 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(
value: bloc,
child: BlocListener<HubDetailsBloc, HubDetailsState>(
listener: (BuildContext context, HubDetailsState state) {
if (state.status == HubDetailsStatus.deleted) {
UiSnackbar.show(
context,
message: state.successMessage ?? 'Hub deleted successfully',
type: UiSnackbarType.success,
);
Modular.to.pop(true); // Return true to indicate change
}
if (state.status == HubDetailsStatus.failure &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: state.errorMessage!,
type: UiSnackbarType.error,
);
}
},
child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(hub.name), title: Text(hub.name),
backgroundColor: UiColors.foreground, backgroundColor: UiColors.foreground,
leading: IconButton( leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Modular.to.pop(),
), ),
actions: <Widget>[ actions: <Widget>[
IconButton(
onPressed: () => _confirmDeleteHub(context),
icon: const Icon(
UiIcons.delete,
color: UiColors.white,
size: 20,
),
),
TextButton.icon( TextButton.icon(
onPressed: () => _navigateToEditPage(context), onPressed: () => _navigateToEditPage(context),
icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16), icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16),
@@ -62,13 +89,17 @@ class HubDetailsPage extends StatelessWidget {
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
_buildDetailItem( _buildDetailItem(
label: t.client_hubs.hub_details.nfc_label, label: t.client_hubs.hub_details.nfc_label,
value: hub.nfcTagId ?? t.client_hubs.hub_details.nfc_not_assigned, value:
hub.nfcTagId ??
t.client_hubs.hub_details.nfc_not_assigned,
icon: UiIcons.nfc, icon: UiIcons.nfc,
isHighlight: hub.nfcTagId != null, isHighlight: hub.nfcTagId != null,
), ),
], ],
), ),
), ),
),
),
); );
} }
@@ -96,7 +127,9 @@ class HubDetailsPage extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(UiConstants.space3), padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isHighlight ? UiColors.tagInProgress : UiColors.bgInputField, color: isHighlight
? UiColors.tagInProgress
: UiColors.bgInputField,
borderRadius: BorderRadius.circular(UiConstants.radiusBase), borderRadius: BorderRadius.circular(UiConstants.radiusBase),
), ),
child: Icon( child: Icon(
@@ -122,16 +155,37 @@ class HubDetailsPage extends StatelessWidget {
} }
Future<void> _navigateToEditPage(BuildContext context) async { Future<void> _navigateToEditPage(BuildContext context) async {
// Navigate to the dedicated edit page and await result. // We still need to pass a Bloc for the edit page, but it's handled by Modular.
// If the page returns `true` (save succeeded), pop the details page too so // However, the Navigator extension expect a Bloc.
// the user sees the refreshed hub list (the BLoC already holds updated data). // I'll update the Navigator extension to NOT require a Bloc since it's in Modular.
final bool? saved = await Navigator.of(context).push<bool>( final bool? saved = await Modular.to.toEditHub(hub: hub);
MaterialPageRoute<bool>( if (saved == true && context.mounted) {
builder: (_) => EditHubPage(hub: hub, bloc: bloc), Modular.to.pop(true); // Return true to indicate change
}
}
Future<void> _confirmDeleteHub(BuildContext context) async {
final bool? confirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text(t.client_hubs.delete_dialog.title),
content: Text(t.client_hubs.delete_dialog.message(hubName: hub.name)),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(t.client_hubs.delete_dialog.cancel),
),
TextButton(
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

@@ -5,14 +5,15 @@ 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.hub,
required this.onNfcPressed, required this.onNfcPressed,
required this.onDeletePressed, required this.onDeletePressed,
required this.onTap,
super.key, super.key,
}); });
/// The hub to display. /// The hub to display.
final Hub hub; final Hub hub;
@@ -22,11 +23,16 @@ class HubCard extends StatelessWidget {
/// Callback when the delete button is pressed. /// Callback when the delete button is pressed.
final VoidCallback onDeletePressed; final VoidCallback onDeletePressed;
/// Callback when the card is tapped.
final VoidCallback onTap;
@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(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3), margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.white, color: UiColors.white,
@@ -116,6 +122,7 @@ class HubCard extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
} }