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:krow_domain/krow_domain.dart';
import 'route_paths.dart';
@@ -145,6 +146,22 @@ extension ClientNavigator on IModularNavigator {
await pushNamed(ClientPaths.hubs);
}
/// Navigates to the details of a specific hub.
Future<bool?> toHubDetails(Hub hub) {
return pushNamed<bool?>(
ClientPaths.hubDetails,
arguments: <String, dynamic>{'hub': hub},
);
}
/// Navigates to the page to add a new hub or edit an existing one.
Future<bool?> toEditHub({Hub? hub}) async {
return pushNamed<bool?>(
ClientPaths.editHub,
arguments: <String, dynamic>{'hub': hub},
);
}
// ==========================================================================
// ORDER CREATION
// ==========================================================================

View File

@@ -82,10 +82,12 @@ class ClientPaths {
static const String billing = '/client-main/billing';
/// Completion review page - review shift completion records.
static const String completionReview = '/client-main/billing/completion-review';
static const String completionReview =
'/client-main/billing/completion-review';
/// Full list of invoices awaiting approval.
static const String awaitingApproval = '/client-main/billing/awaiting-approval';
static const String awaitingApproval =
'/client-main/billing/awaiting-approval';
/// Invoice ready page - view status of approved invoices.
static const String invoiceReady = '/client-main/billing/invoice-ready';
@@ -118,6 +120,12 @@ class ClientPaths {
/// View and manage physical locations/hubs where staff are deployed.
static const String hubs = '/client-hubs';
/// Specific hub details.
static const String hubDetails = '/client-hubs/details';
/// Page for adding or editing a hub.
static const String editHub = '/client-hubs/edit';
// ==========================================================================
// ORDER CREATION & MANAGEMENT
// ==========================================================================

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/update_hub_usecase.dart';
import 'src/presentation/blocs/client_hubs_bloc.dart';
import 'src/presentation/blocs/edit_hub/edit_hub_bloc.dart';
import 'src/presentation/blocs/hub_details/hub_details_bloc.dart';
import 'src/presentation/pages/client_hubs_page.dart';
import 'src/presentation/pages/edit_hub_page.dart';
import 'src/presentation/pages/hub_details_page.dart';
import 'package:krow_domain/krow_domain.dart';
export 'src/presentation/pages/client_hubs_page.dart';
@@ -34,10 +39,35 @@ class ClientHubsModule extends Module {
// BLoCs
i.add<ClientHubsBloc>(ClientHubsBloc.new);
i.add<EditHubBloc>(EditHubBloc.new);
i.add<HubDetailsBloc>(HubDetailsBloc.new);
}
@override
void routes(RouteManager r) {
r.child(ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubs), child: (_) => const ClientHubsPage());
r.child(
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubs),
child: (_) => const ClientHubsPage(),
);
r.child(
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubDetails),
child: (_) {
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
return HubDetailsPage(
hub: data['hub'] as Hub,
bloc: Modular.get<HubDetailsBloc>(),
);
},
);
r.child(
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.editHub),
child: (_) {
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
return EditHubPage(
hub: data['hub'] as Hub?,
bloc: Modular.get<EditHubBloc>(),
);
},
);
}
}

View File

@@ -3,57 +3,38 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/arguments/assign_nfc_tag_arguments.dart';
import '../../domain/arguments/create_hub_arguments.dart';
import '../../domain/arguments/delete_hub_arguments.dart';
import '../../domain/usecases/assign_nfc_tag_usecase.dart';
import '../../domain/usecases/create_hub_usecase.dart';
import '../../domain/usecases/delete_hub_usecase.dart';
import '../../domain/usecases/get_hubs_usecase.dart';
import '../../domain/usecases/update_hub_usecase.dart';
import 'client_hubs_event.dart';
import 'client_hubs_state.dart';
/// BLoC responsible for managing the state of the Client Hubs feature.
///
/// It orchestrates the flow between the UI and the domain layer by invoking
/// specific use cases for fetching, creating, deleting, and assigning tags to hubs.
/// specific use cases for fetching, deleting, and assigning tags to hubs.
class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
with BlocErrorHandler<ClientHubsState>
implements Disposable {
ClientHubsBloc({
required GetHubsUseCase getHubsUseCase,
required CreateHubUseCase createHubUseCase,
required DeleteHubUseCase deleteHubUseCase,
required AssignNfcTagUseCase assignNfcTagUseCase,
required UpdateHubUseCase updateHubUseCase,
}) : _getHubsUseCase = getHubsUseCase,
_createHubUseCase = createHubUseCase,
_deleteHubUseCase = deleteHubUseCase,
_assignNfcTagUseCase = assignNfcTagUseCase,
_updateHubUseCase = updateHubUseCase,
super(const ClientHubsState()) {
on<ClientHubsFetched>(_onFetched);
on<ClientHubsAddRequested>(_onAddRequested);
on<ClientHubsUpdateRequested>(_onUpdateRequested);
on<ClientHubsDeleteRequested>(_onDeleteRequested);
on<ClientHubsNfcTagAssignRequested>(_onNfcTagAssignRequested);
on<ClientHubsMessageCleared>(_onMessageCleared);
on<ClientHubsAddDialogToggled>(_onAddDialogToggled);
on<ClientHubsIdentifyDialogToggled>(_onIdentifyDialogToggled);
}
final GetHubsUseCase _getHubsUseCase;
final CreateHubUseCase _createHubUseCase;
final DeleteHubUseCase _deleteHubUseCase;
final AssignNfcTagUseCase _assignNfcTagUseCase;
final UpdateHubUseCase _updateHubUseCase;
void _onAddDialogToggled(
ClientHubsAddDialogToggled event,
Emitter<ClientHubsState> emit,
) {
emit(state.copyWith(showAddHubDialog: event.visible));
}
void _onIdentifyDialogToggled(
ClientHubsIdentifyDialogToggled event,
@@ -75,7 +56,7 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
await handleError(
emit: emit.call,
action: () async {
final List<Hub> hubs = await _getHubsUseCase();
final List<Hub> hubs = await _getHubsUseCase.call();
emit(state.copyWith(status: ClientHubsStatus.success, hubs: hubs));
},
onError: (String errorKey) => state.copyWith(
@@ -85,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(
ClientHubsDeleteRequested event,
Emitter<ClientHubsState> emit,
@@ -174,8 +75,8 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
await handleError(
emit: emit.call,
action: () async {
await _deleteHubUseCase(DeleteHubArguments(hubId: event.hubId));
final List<Hub> hubs = await _getHubsUseCase();
await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.hubId));
final List<Hub> hubs = await _getHubsUseCase.call();
emit(
state.copyWith(
status: ClientHubsStatus.actionSuccess,
@@ -200,10 +101,10 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
await handleError(
emit: emit.call,
action: () async {
await _assignNfcTagUseCase(
await _assignNfcTagUseCase.call(
AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId),
);
final List<Hub> hubs = await _getHubsUseCase();
final List<Hub> hubs = await _getHubsUseCase.call();
emit(
state.copyWith(
status: ClientHubsStatus.actionSuccess,

View File

@@ -14,94 +14,8 @@ class ClientHubsFetched extends ClientHubsEvent {
const ClientHubsFetched();
}
/// Event triggered to add a new hub.
class ClientHubsAddRequested extends ClientHubsEvent {
const ClientHubsAddRequested({
required this.name,
required this.address,
this.placeId,
this.latitude,
this.longitude,
this.city,
this.state,
this.street,
this.country,
this.zipCode,
});
final String name;
final String address;
final String? placeId;
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? street;
final String? country;
final String? zipCode;
@override
List<Object?> get props => <Object?>[
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
];
}
/// Event triggered to update an existing hub.
class ClientHubsUpdateRequested extends ClientHubsEvent {
const ClientHubsUpdateRequested({
required this.id,
required this.name,
required this.address,
this.placeId,
this.latitude,
this.longitude,
this.city,
this.state,
this.street,
this.country,
this.zipCode,
});
final String id;
final String name;
final String address;
final String? placeId;
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? street;
final String? country;
final String? zipCode;
@override
List<Object?> get props => <Object?>[
id,
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
];
}
/// Event triggered to delete a hub.
class ClientHubsDeleteRequested extends ClientHubsEvent {
const ClientHubsDeleteRequested(this.hubId);
final String hubId;
@@ -111,7 +25,6 @@ class ClientHubsDeleteRequested extends ClientHubsEvent {
/// Event triggered to assign an NFC tag to a hub.
class ClientHubsNfcTagAssignRequested extends ClientHubsEvent {
const ClientHubsNfcTagAssignRequested({
required this.hubId,
required this.nfcTagId,
@@ -128,19 +41,8 @@ class ClientHubsMessageCleared extends ClientHubsEvent {
const ClientHubsMessageCleared();
}
/// Event triggered to toggle the visibility of the "Add Hub" dialog.
class ClientHubsAddDialogToggled extends ClientHubsEvent {
const ClientHubsAddDialogToggled({required this.visible});
final bool visible;
@override
List<Object?> get props => <Object?>[visible];
}
/// Event triggered to toggle the visibility of the "Identify NFC" dialog.
class ClientHubsIdentifyDialogToggled extends ClientHubsEvent {
const ClientHubsIdentifyDialogToggled({this.hub});
final Hub? hub;

View File

@@ -14,23 +14,19 @@ enum ClientHubsStatus {
/// State class for the ClientHubs BLoC.
class ClientHubsState extends Equatable {
const ClientHubsState({
this.status = ClientHubsStatus.initial,
this.hubs = const <Hub>[],
this.errorMessage,
this.successMessage,
this.showAddHubDialog = false,
this.hubToIdentify,
});
final ClientHubsStatus status;
final List<Hub> hubs;
final String? errorMessage;
final String? successMessage;
/// Whether the "Add Hub" dialog should be visible.
final bool showAddHubDialog;
/// The hub currently being identified/assigned an NFC tag.
/// If null, the identification dialog is closed.
final Hub? hubToIdentify;
@@ -40,7 +36,6 @@ class ClientHubsState extends Equatable {
List<Hub>? hubs,
String? errorMessage,
String? successMessage,
bool? showAddHubDialog,
Hub? hubToIdentify,
bool clearHubToIdentify = false,
bool clearErrorMessage = false,
@@ -55,7 +50,6 @@ class ClientHubsState extends Equatable {
successMessage: clearSuccessMessage
? null
: (successMessage ?? this.successMessage),
showAddHubDialog: showAddHubDialog ?? this.showAddHubDialog,
hubToIdentify: clearHubToIdentify
? null
: (hubToIdentify ?? this.hubToIdentify),
@@ -68,7 +62,6 @@ class ClientHubsState extends Equatable {
hubs,
errorMessage,
successMessage,
showAddHubDialog,
hubToIdentify,
];
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import '../../../domain/arguments/create_hub_arguments.dart';
import '../../../domain/usecases/create_hub_usecase.dart';
import '../../../domain/usecases/update_hub_usecase.dart';
import 'edit_hub_event.dart';
import 'edit_hub_state.dart';
/// Bloc for creating and updating hubs.
class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
with BlocErrorHandler<EditHubState> {
EditHubBloc({
required CreateHubUseCase createHubUseCase,
required UpdateHubUseCase updateHubUseCase,
}) : _createHubUseCase = createHubUseCase,
_updateHubUseCase = updateHubUseCase,
super(const EditHubState()) {
on<EditHubAddRequested>(_onAddRequested);
on<EditHubUpdateRequested>(_onUpdateRequested);
}
final CreateHubUseCase _createHubUseCase;
final UpdateHubUseCase _updateHubUseCase;
Future<void> _onAddRequested(
EditHubAddRequested event,
Emitter<EditHubState> emit,
) async {
emit(state.copyWith(status: EditHubStatus.loading));
await handleError(
emit: emit,
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_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';
@@ -43,7 +43,8 @@ class ClientHubsPage extends StatelessWidget {
context,
).add(const ClientHubsMessageCleared());
}
if (state.successMessage != null && state.successMessage!.isNotEmpty) {
if (state.successMessage != null &&
state.successMessage!.isNotEmpty) {
UiSnackbar.show(
context,
message: state.successMessage!,
@@ -58,9 +59,14 @@ class ClientHubsPage extends StatelessWidget {
return Scaffold(
backgroundColor: UiColors.bgMenu,
floatingActionButton: FloatingActionButton(
onPressed: () => BlocProvider.of<ClientHubsBloc>(
onPressed: () async {
final bool? success = await Modular.to.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsAddDialogToggled(visible: true)),
).add(const ClientHubsFetched());
}
},
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
@@ -82,27 +88,37 @@ class ClientHubsPage extends StatelessWidget {
const Center(child: CircularProgressIndicator())
else if (state.hubs.isEmpty)
HubEmptyState(
onAddPressed: () =>
BlocProvider.of<ClientHubsBloc>(context).add(
const ClientHubsAddDialogToggled(
visible: true,
),
),
onAddPressed: () async {
final bool? success = await Modular.to
.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
)
else ...<Widget>[
...state.hubs.map(
(Hub hub) => HubCard(
hub: hub,
onTap: () async {
final bool? success = await Modular.to
.toHubDetails(hub);
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
onNfcPressed: () =>
BlocProvider.of<ClientHubsBloc>(
context,
).add(
ClientHubsIdentifyDialogToggled(hub: hub),
),
onDeletePressed: () => _confirmDeleteHub(
context,
hub,
),
onDeletePressed: () =>
_confirmDeleteHub(context, 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)
IdentifyNfcDialog(
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:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart';
import '../blocs/client_hubs_event.dart';
import '../blocs/client_hubs_state.dart';
import '../blocs/edit_hub/edit_hub_bloc.dart';
import '../blocs/edit_hub/edit_hub_event.dart';
import '../blocs/edit_hub/edit_hub_state.dart';
import '../widgets/hub_address_autocomplete.dart';
/// A dedicated full-screen page for editing an existing hub.
///
/// Takes the parent [ClientHubsBloc] via [BlocProvider.value] so the
/// updated hub list is reflected on the hubs list page when the user
/// saves and navigates back.
/// A dedicated full-screen page for adding or editing a hub.
class EditHubPage extends StatefulWidget {
const EditHubPage({
required this.hub,
required this.bloc,
super.key,
});
const EditHubPage({this.hub, required this.bloc, super.key});
final Hub hub;
final ClientHubsBloc bloc;
final Hub? hub;
final EditHubBloc bloc;
@override
State<EditHubPage> createState() => _EditHubPageState();
@@ -39,8 +32,8 @@ class _EditHubPageState extends State<EditHubPage> {
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.hub.name);
_addressController = TextEditingController(text: widget.hub.address);
_nameController = TextEditingController(text: widget.hub?.name);
_addressController = TextEditingController(text: widget.hub?.address);
_addressFocusNode = FocusNode();
}
@@ -64,9 +57,20 @@ class _EditHubPageState extends State<EditHubPage> {
return;
}
ReadContext(context).read<ClientHubsBloc>().add(
ClientHubsUpdateRequested(
id: widget.hub.id,
if (widget.hub == null) {
widget.bloc.add(
EditHubAddRequested(
name: _nameController.text.trim(),
address: _addressController.text.trim(),
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
),
);
} else {
widget.bloc.add(
EditHubUpdateRequested(
id: widget.hub!.id,
name: _nameController.text.trim(),
address: _addressController.text.trim(),
placeId: _selectedPrediction?.placeId,
@@ -75,26 +79,28 @@ class _EditHubPageState extends State<EditHubPage> {
),
);
}
}
@override
Widget build(BuildContext context) {
return BlocProvider<ClientHubsBloc>.value(
return BlocProvider<EditHubBloc>.value(
value: widget.bloc,
child: BlocListener<ClientHubsBloc, ClientHubsState>(
listenWhen: (ClientHubsState prev, ClientHubsState curr) =>
prev.status != curr.status || prev.successMessage != curr.successMessage,
listener: (BuildContext context, ClientHubsState state) {
if (state.status == ClientHubsStatus.actionSuccess &&
child: BlocListener<EditHubBloc, EditHubState>(
listenWhen: (EditHubState prev, EditHubState curr) =>
prev.status != curr.status ||
prev.successMessage != curr.successMessage,
listener: (BuildContext context, EditHubState state) {
if (state.status == EditHubStatus.success &&
state.successMessage != null) {
UiSnackbar.show(
context,
message: state.successMessage!,
type: UiSnackbarType.success,
);
// Pop back to details page with updated hub
Navigator.of(context).pop(true);
// Pop back to the previous screen.
Modular.to.pop(true);
}
if (state.status == ClientHubsStatus.actionFailure &&
if (state.status == EditHubStatus.failure &&
state.errorMessage != null) {
UiSnackbar.show(
context,
@@ -103,10 +109,9 @@ class _EditHubPageState extends State<EditHubPage> {
);
}
},
child: BlocBuilder<ClientHubsBloc, ClientHubsState>(
builder: (BuildContext context, ClientHubsState state) {
final bool isSaving =
state.status == ClientHubsStatus.actionInProgress;
child: BlocBuilder<EditHubBloc, EditHubState>(
builder: (BuildContext context, EditHubState state) {
final bool isSaving = state.status == EditHubStatus.loading;
return Scaffold(
backgroundColor: UiColors.bgMenu,
@@ -114,17 +119,21 @@ class _EditHubPageState extends State<EditHubPage> {
backgroundColor: UiColors.foreground,
leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Modular.to.pop(),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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,
),
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(
color: UiColors.white.withValues(alpha: 0.7),
),
@@ -176,7 +185,9 @@ class _EditHubPageState extends State<EditHubPage> {
// ── Save button ──────────────────────────────────
UiButton.primary(
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),

View File

@@ -1,37 +1,64 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart';
import 'edit_hub_page.dart';
import '../blocs/hub_details/hub_details_bloc.dart';
import '../blocs/hub_details/hub_details_event.dart';
import '../blocs/hub_details/hub_details_state.dart';
/// A read-only details page for a single [Hub].
///
/// Shows hub name, address, and NFC tag assignment.
/// Tapping the edit button navigates to [EditHubPage] (a dedicated page,
/// not a dialog), satisfying the "separate edit hub page" acceptance criterion.
class HubDetailsPage extends StatelessWidget {
const HubDetailsPage({
required this.hub,
required this.bloc,
super.key,
});
const HubDetailsPage({required this.hub, required this.bloc, super.key});
final Hub hub;
final ClientHubsBloc bloc;
final HubDetailsBloc bloc;
@override
Widget build(BuildContext context) {
return Scaffold(
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(
title: Text(hub.name),
backgroundColor: UiColors.foreground,
leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Modular.to.pop(),
),
actions: <Widget>[
IconButton(
onPressed: () => _confirmDeleteHub(context),
icon: const Icon(
UiIcons.delete,
color: UiColors.white,
size: 20,
),
),
TextButton.icon(
onPressed: () => _navigateToEditPage(context),
icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16),
@@ -62,13 +89,17 @@ class HubDetailsPage extends StatelessWidget {
const SizedBox(height: UiConstants.space4),
_buildDetailItem(
label: t.client_hubs.hub_details.nfc_label,
value: hub.nfcTagId ?? t.client_hubs.hub_details.nfc_not_assigned,
value:
hub.nfcTagId ??
t.client_hubs.hub_details.nfc_not_assigned,
icon: UiIcons.nfc,
isHighlight: hub.nfcTagId != null,
),
],
),
),
),
),
);
}
@@ -96,7 +127,9 @@ class HubDetailsPage extends StatelessWidget {
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: isHighlight ? UiColors.tagInProgress : UiColors.bgInputField,
color: isHighlight
? UiColors.tagInProgress
: UiColors.bgInputField,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Icon(
@@ -122,16 +155,37 @@ class HubDetailsPage extends StatelessWidget {
}
Future<void> _navigateToEditPage(BuildContext context) async {
// Navigate to the dedicated edit page and await result.
// If the page returns `true` (save succeeded), pop the details page too so
// the user sees the refreshed hub list (the BLoC already holds updated data).
final bool? saved = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => EditHubPage(hub: hub, bloc: bloc),
// We still need to pass a Bloc for the edit page, but it's handled by Modular.
// However, the Navigator extension expect a Bloc.
// I'll update the Navigator extension to NOT require a Bloc since it's in Modular.
final bool? saved = await Modular.to.toEditHub(hub: hub);
if (saved == true && context.mounted) {
Modular.to.pop(true); // Return true to indicate change
}
}
Future<void> _confirmDeleteHub(BuildContext context) async {
final bool? confirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text(t.client_hubs.delete_dialog.title),
content: Text(t.client_hubs.delete_dialog.message(hubName: hub.name)),
actions: <Widget>[
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.
class HubCard extends StatelessWidget {
/// Creates a [HubCard].
const HubCard({
required this.hub,
required this.onNfcPressed,
required this.onDeletePressed,
required this.onTap,
super.key,
});
/// The hub to display.
final Hub hub;
@@ -22,11 +23,16 @@ class HubCard extends StatelessWidget {
/// Callback when the delete button is pressed.
final VoidCallback onDeletePressed;
/// Callback when the card is tapped.
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final bool hasNfc = hub.nfcTagId != null;
return Container(
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.white,
@@ -116,6 +122,7 @@ class HubCard extends StatelessWidget {
],
),
),
),
);
}
}