feat: refactor hub management to use dedicated pages for adding, editing, and viewing hub details.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'route_paths.dart';
|
||||
|
||||
@@ -145,6 +146,22 @@ extension ClientNavigator on IModularNavigator {
|
||||
await pushNamed(ClientPaths.hubs);
|
||||
}
|
||||
|
||||
/// Navigates to the details of a specific hub.
|
||||
Future<bool?> toHubDetails(Hub hub) {
|
||||
return pushNamed<bool?>(
|
||||
ClientPaths.hubDetails,
|
||||
arguments: <String, dynamic>{'hub': hub},
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigates to the page to add a new hub or edit an existing one.
|
||||
Future<bool?> toEditHub({Hub? hub}) async {
|
||||
return pushNamed<bool?>(
|
||||
ClientPaths.editHub,
|
||||
arguments: <String, dynamic>{'hub': hub},
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ORDER CREATION
|
||||
// ==========================================================================
|
||||
|
||||
@@ -16,14 +16,14 @@ class ClientPaths {
|
||||
/// Generate child route based on the given route and parent route
|
||||
///
|
||||
/// This is useful for creating nested routes within modules.
|
||||
static String childRoute(String parent, String child) {
|
||||
static String childRoute(String parent, String child) {
|
||||
final String childPath = child.replaceFirst(parent, '');
|
||||
|
||||
|
||||
// check if the child path is empty
|
||||
if (childPath.isEmpty) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ensure the child path starts with a '/'
|
||||
if (!childPath.startsWith('/')) {
|
||||
return '/$childPath';
|
||||
@@ -82,10 +82,12 @@ class ClientPaths {
|
||||
static const String billing = '/client-main/billing';
|
||||
|
||||
/// Completion review page - review shift completion records.
|
||||
static const String completionReview = '/client-main/billing/completion-review';
|
||||
static const String completionReview =
|
||||
'/client-main/billing/completion-review';
|
||||
|
||||
/// Full list of invoices awaiting approval.
|
||||
static const String awaitingApproval = '/client-main/billing/awaiting-approval';
|
||||
static const String awaitingApproval =
|
||||
'/client-main/billing/awaiting-approval';
|
||||
|
||||
/// Invoice ready page - view status of approved invoices.
|
||||
static const String invoiceReady = '/client-main/billing/invoice-ready';
|
||||
@@ -118,6 +120,12 @@ class ClientPaths {
|
||||
/// View and manage physical locations/hubs where staff are deployed.
|
||||
static const String hubs = '/client-hubs';
|
||||
|
||||
/// Specific hub details.
|
||||
static const String hubDetails = '/client-hubs/details';
|
||||
|
||||
/// Page for adding or editing a hub.
|
||||
static const String editHub = '/client-hubs/edit';
|
||||
|
||||
// ==========================================================================
|
||||
// ORDER CREATION & MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
@@ -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>(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -71,11 +52,11 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
|
||||
Emitter<ClientHubsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHubsStatus.loading));
|
||||
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<Hub> hubs = await _getHubsUseCase();
|
||||
final List<Hub> hubs = await _getHubsUseCase.call();
|
||||
emit(state.copyWith(status: ClientHubsStatus.success, hubs: hubs));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
@@ -85,97 +66,17 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onAddRequested(
|
||||
ClientHubsAddRequested event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _createHubUseCase(
|
||||
CreateHubArguments(
|
||||
name: event.name,
|
||||
address: event.address,
|
||||
placeId: event.placeId,
|
||||
latitude: event.latitude,
|
||||
longitude: event.longitude,
|
||||
city: event.city,
|
||||
state: event.state,
|
||||
street: event.street,
|
||||
country: event.country,
|
||||
zipCode: event.zipCode,
|
||||
),
|
||||
);
|
||||
final List<Hub> hubs = await _getHubsUseCase();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientHubsStatus.actionSuccess,
|
||||
hubs: hubs,
|
||||
successMessage: 'Hub created successfully',
|
||||
showAddHubDialog: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClientHubsStatus.actionFailure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onUpdateRequested(
|
||||
ClientHubsUpdateRequested event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _updateHubUseCase(
|
||||
UpdateHubArguments(
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
address: event.address,
|
||||
placeId: event.placeId,
|
||||
latitude: event.latitude,
|
||||
longitude: event.longitude,
|
||||
city: event.city,
|
||||
state: event.state,
|
||||
street: event.street,
|
||||
country: event.country,
|
||||
zipCode: event.zipCode,
|
||||
),
|
||||
);
|
||||
final List<Hub> hubs = await _getHubsUseCase();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientHubsStatus.actionSuccess,
|
||||
hubs: hubs,
|
||||
successMessage: 'Hub updated successfully!',
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: ClientHubsStatus.actionFailure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onDeleteRequested(
|
||||
ClientHubsDeleteRequested event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
|
||||
|
||||
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await _deleteHubUseCase(DeleteHubArguments(hubId: event.hubId));
|
||||
final List<Hub> hubs = await _getHubsUseCase();
|
||||
await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.hubId));
|
||||
final List<Hub> hubs = await _getHubsUseCase.call();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientHubsStatus.actionSuccess,
|
||||
@@ -196,14 +97,14 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
|
||||
Emitter<ClientHubsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Base class for all edit hub events.
|
||||
abstract class EditHubEvent extends Equatable {
|
||||
const EditHubEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Event triggered to add a new hub.
|
||||
class EditHubAddRequested extends EditHubEvent {
|
||||
const EditHubAddRequested({
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.placeId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.city,
|
||||
this.state,
|
||||
this.street,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String address;
|
||||
final String? placeId;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? city;
|
||||
final String? state;
|
||||
final String? street;
|
||||
final String? country;
|
||||
final String? zipCode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
|
||||
/// Event triggered to update an existing hub.
|
||||
class EditHubUpdateRequested extends EditHubEvent {
|
||||
const EditHubUpdateRequested({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.placeId,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.city,
|
||||
this.state,
|
||||
this.street,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String address;
|
||||
final String? placeId;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? city;
|
||||
final String? state;
|
||||
final String? street;
|
||||
final String? country;
|
||||
final String? zipCode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Status of the edit hub operation.
|
||||
enum EditHubStatus {
|
||||
/// Initial state.
|
||||
initial,
|
||||
|
||||
/// Operation in progress.
|
||||
loading,
|
||||
|
||||
/// Operation succeeded.
|
||||
success,
|
||||
|
||||
/// Operation failed.
|
||||
failure,
|
||||
}
|
||||
|
||||
/// State for the edit hub operation.
|
||||
class EditHubState extends Equatable {
|
||||
const EditHubState({
|
||||
this.status = EditHubStatus.initial,
|
||||
this.errorMessage,
|
||||
this.successMessage,
|
||||
});
|
||||
|
||||
/// The status of the operation.
|
||||
final EditHubStatus status;
|
||||
|
||||
/// The error message if the operation failed.
|
||||
final String? errorMessage;
|
||||
|
||||
/// The success message if the operation succeeded.
|
||||
final String? successMessage;
|
||||
|
||||
/// Create a copy of this state with the given fields replaced.
|
||||
EditHubState copyWith({
|
||||
EditHubStatus? status,
|
||||
String? errorMessage,
|
||||
String? successMessage,
|
||||
}) {
|
||||
return EditHubState(
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
successMessage: successMessage ?? this.successMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[status, errorMessage, successMessage];
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../../domain/arguments/assign_nfc_tag_arguments.dart';
|
||||
import '../../../domain/arguments/delete_hub_arguments.dart';
|
||||
import '../../../domain/usecases/assign_nfc_tag_usecase.dart';
|
||||
import '../../../domain/usecases/delete_hub_usecase.dart';
|
||||
import 'hub_details_event.dart';
|
||||
import 'hub_details_state.dart';
|
||||
|
||||
/// Bloc for managing hub details and operations like delete and NFC assignment.
|
||||
class HubDetailsBloc extends Bloc<HubDetailsEvent, HubDetailsState>
|
||||
with BlocErrorHandler<HubDetailsState> {
|
||||
HubDetailsBloc({
|
||||
required DeleteHubUseCase deleteHubUseCase,
|
||||
required AssignNfcTagUseCase assignNfcTagUseCase,
|
||||
}) : _deleteHubUseCase = deleteHubUseCase,
|
||||
_assignNfcTagUseCase = assignNfcTagUseCase,
|
||||
super(const HubDetailsState()) {
|
||||
on<HubDetailsDeleteRequested>(_onDeleteRequested);
|
||||
on<HubDetailsNfcTagAssignRequested>(_onNfcTagAssignRequested);
|
||||
}
|
||||
|
||||
final DeleteHubUseCase _deleteHubUseCase;
|
||||
final AssignNfcTagUseCase _assignNfcTagUseCase;
|
||||
|
||||
Future<void> _onDeleteRequested(
|
||||
HubDetailsDeleteRequested event,
|
||||
Emitter<HubDetailsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: HubDetailsStatus.loading));
|
||||
|
||||
await handleError(
|
||||
emit: emit,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Base class for all hub details events.
|
||||
abstract class HubDetailsEvent extends Equatable {
|
||||
const HubDetailsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Event triggered to delete a hub.
|
||||
class HubDetailsDeleteRequested extends HubDetailsEvent {
|
||||
const HubDetailsDeleteRequested(this.id);
|
||||
final String id;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id];
|
||||
}
|
||||
|
||||
/// Event triggered to assign an NFC tag to a hub.
|
||||
class HubDetailsNfcTagAssignRequested extends HubDetailsEvent {
|
||||
const HubDetailsNfcTagAssignRequested({
|
||||
required this.hubId,
|
||||
required this.nfcTagId,
|
||||
});
|
||||
|
||||
final String hubId;
|
||||
final String nfcTagId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[hubId, nfcTagId];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Status of the hub details operation.
|
||||
enum HubDetailsStatus {
|
||||
/// Initial state.
|
||||
initial,
|
||||
|
||||
/// Operation in progress.
|
||||
loading,
|
||||
|
||||
/// Operation succeeded.
|
||||
success,
|
||||
|
||||
/// Operation failed.
|
||||
failure,
|
||||
|
||||
/// Hub was deleted.
|
||||
deleted,
|
||||
}
|
||||
|
||||
/// State for the hub details operation.
|
||||
class HubDetailsState extends Equatable {
|
||||
const HubDetailsState({
|
||||
this.status = HubDetailsStatus.initial,
|
||||
this.errorMessage,
|
||||
this.successMessage,
|
||||
});
|
||||
|
||||
/// The status of the operation.
|
||||
final HubDetailsStatus status;
|
||||
|
||||
/// The error message if the operation failed.
|
||||
final String? errorMessage;
|
||||
|
||||
/// The success message if the operation succeeded.
|
||||
final String? successMessage;
|
||||
|
||||
/// Create a copy of this state with the given fields replaced.
|
||||
HubDetailsState copyWith({
|
||||
HubDetailsStatus? status,
|
||||
String? errorMessage,
|
||||
String? successMessage,
|
||||
}) {
|
||||
return HubDetailsState(
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
successMessage: successMessage ?? this.successMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[status, errorMessage, successMessage];
|
||||
}
|
||||
@@ -8,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>(
|
||||
context,
|
||||
).add(const ClientHubsAddDialogToggled(visible: true)),
|
||||
onPressed: () async {
|
||||
final bool? success = await Modular.to.toEditHub();
|
||||
if (success == true && context.mounted) {
|
||||
BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(const ClientHubsFetched());
|
||||
}
|
||||
},
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
@@ -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!,
|
||||
|
||||
@@ -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,37 +57,50 @@ class _EditHubPageState extends State<EditHubPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
ReadContext(context).read<ClientHubsBloc>().add(
|
||||
ClientHubsUpdateRequested(
|
||||
id: widget.hub.id,
|
||||
name: _nameController.text.trim(),
|
||||
address: _addressController.text.trim(),
|
||||
placeId: _selectedPrediction?.placeId,
|
||||
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
|
||||
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
|
||||
),
|
||||
);
|
||||
if (widget.hub == null) {
|
||||
widget.bloc.add(
|
||||
EditHubAddRequested(
|
||||
name: _nameController.text.trim(),
|
||||
address: _addressController.text.trim(),
|
||||
placeId: _selectedPrediction?.placeId,
|
||||
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
|
||||
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
widget.bloc.add(
|
||||
EditHubUpdateRequested(
|
||||
id: widget.hub!.id,
|
||||
name: _nameController.text.trim(),
|
||||
address: _addressController.text.trim(),
|
||||
placeId: _selectedPrediction?.placeId,
|
||||
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
|
||||
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ClientHubsBloc>.value(
|
||||
return BlocProvider<EditHubBloc>.value(
|
||||
value: widget.bloc,
|
||||
child: BlocListener<ClientHubsBloc, ClientHubsState>(
|
||||
listenWhen: (ClientHubsState prev, ClientHubsState curr) =>
|
||||
prev.status != curr.status || prev.successMessage != curr.successMessage,
|
||||
listener: (BuildContext context, ClientHubsState state) {
|
||||
if (state.status == ClientHubsStatus.actionSuccess &&
|
||||
child: BlocListener<EditHubBloc, EditHubState>(
|
||||
listenWhen: (EditHubState prev, EditHubState curr) =>
|
||||
prev.status != curr.status ||
|
||||
prev.successMessage != curr.successMessage,
|
||||
listener: (BuildContext context, EditHubState state) {
|
||||
if (state.status == EditHubStatus.success &&
|
||||
state.successMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.successMessage!,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
// Pop back to details page with updated hub
|
||||
Navigator.of(context).pop(true);
|
||||
// Pop back to the previous screen.
|
||||
Modular.to.pop(true);
|
||||
}
|
||||
if (state.status == ClientHubsStatus.actionFailure &&
|
||||
if (state.status == EditHubStatus.failure &&
|
||||
state.errorMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
@@ -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),
|
||||
|
||||
@@ -1,72 +1,103 @@
|
||||
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(
|
||||
appBar: AppBar(
|
||||
title: Text(hub.name),
|
||||
backgroundColor: UiColors.foreground,
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton.icon(
|
||||
onPressed: () => _navigateToEditPage(context),
|
||||
icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16),
|
||||
label: Text(
|
||||
t.client_hubs.hub_details.edit_button,
|
||||
style: const TextStyle(color: UiColors.white),
|
||||
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: () => 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),
|
||||
label: Text(
|
||||
t.client_hubs.hub_details.edit_button,
|
||||
style: const TextStyle(color: UiColors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_buildDetailItem(
|
||||
label: t.client_hubs.hub_details.name_label,
|
||||
value: hub.name,
|
||||
icon: UiIcons.home,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildDetailItem(
|
||||
label: t.client_hubs.hub_details.address_label,
|
||||
value: hub.address,
|
||||
icon: UiIcons.mapPin,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildDetailItem(
|
||||
label: t.client_hubs.hub_details.nfc_label,
|
||||
value:
|
||||
hub.nfcTagId ??
|
||||
t.client_hubs.hub_details.nfc_not_assigned,
|
||||
icon: UiIcons.nfc,
|
||||
isHighlight: hub.nfcTagId != null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_buildDetailItem(
|
||||
label: t.client_hubs.hub_details.name_label,
|
||||
value: hub.name,
|
||||
icon: UiIcons.home,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildDetailItem(
|
||||
label: t.client_hubs.hub_details.address_label,
|
||||
value: hub.address,
|
||||
icon: UiIcons.mapPin,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildDetailItem(
|
||||
label: t.client_hubs.hub_details.nfc_label,
|
||||
value: hub.nfcTagId ?? t.client_hubs.hub_details.nfc_not_assigned,
|
||||
icon: UiIcons.nfc,
|
||||
isHighlight: hub.nfcTagId != null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,99 +23,105 @@ 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(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
boxShadow: const <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.popupShadow,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagInProgress,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: Icon(
|
||||
hasNfc ? UiIcons.success : UiIcons.nfc,
|
||||
color: hasNfc ? UiColors.iconSuccess : UiColors.iconThird,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(hub.name, style: UiTypography.body1b.textPrimary),
|
||||
if (hub.address.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 12,
|
||||
color: UiColors.iconThird,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hub.address,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasNfc)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Text(
|
||||
t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!),
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: UiColors.textSuccess,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
onPressed: onDeletePressed,
|
||||
icon: const Icon(
|
||||
UiIcons.delete,
|
||||
color: UiColors.destructive,
|
||||
size: 20,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
splashRadius: 20,
|
||||
),
|
||||
],
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
boxShadow: const <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.popupShadow,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagInProgress,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: Icon(
|
||||
hasNfc ? UiIcons.success : UiIcons.nfc,
|
||||
color: hasNfc ? UiColors.iconSuccess : UiColors.iconThird,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(hub.name, style: UiTypography.body1b.textPrimary),
|
||||
if (hub.address.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 12,
|
||||
color: UiColors.iconThird,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hub.address,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasNfc)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space1),
|
||||
child: Text(
|
||||
t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!),
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: UiColors.textSuccess,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
onPressed: onDeletePressed,
|
||||
icon: const Icon(
|
||||
UiIcons.delete,
|
||||
color: UiColors.destructive,
|
||||
size: 20,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
splashRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user