hub & manager issues

This commit is contained in:
2026-02-25 19:58:28 +05:30
parent 27754524f5
commit eeb8c28a61
53 changed files with 1571 additions and 245 deletions

View File

@@ -24,6 +24,17 @@ class HubRepositoryImpl implements HubRepositoryInterface {
return _connectorRepository.getHubs(businessId: businessId);
}
@override
Future<List<CostCenter>> getCostCenters() async {
// Mocking cost centers for now since the backend is not yet ready.
return <CostCenter>[
const CostCenter(id: 'cc-001', name: 'Kitchen', code: '1001'),
const CostCenter(id: 'cc-002', name: 'Front Desk', code: '1002'),
const CostCenter(id: 'cc-003', name: 'Waitstaff', code: '1003'),
const CostCenter(id: 'cc-004', name: 'Management', code: '1004'),
];
}
@override
Future<Hub> createHub({
required String name,
@@ -36,7 +47,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? street,
String? country,
String? zipCode,
String? costCenter,
String? costCenterId,
}) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.createHub(
@@ -80,7 +91,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? street,
String? country,
String? zipCode,
String? costCenter,
String? costCenterId,
}) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.updateHub(

View File

@@ -19,7 +19,7 @@ class CreateHubArguments extends UseCaseArgument {
this.street,
this.country,
this.zipCode,
this.costCenter,
this.costCenterId,
});
/// The name of the hub.
final String name;
@@ -37,7 +37,7 @@ class CreateHubArguments extends UseCaseArgument {
final String? zipCode;
/// The cost center of the hub.
final String? costCenter;
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
@@ -51,6 +51,6 @@ class CreateHubArguments extends UseCaseArgument {
street,
country,
zipCode,
costCenter,
costCenterId,
];
}

View File

@@ -11,6 +11,9 @@ abstract interface class HubRepositoryInterface {
/// Returns a list of [Hub] entities.
Future<List<Hub>> getHubs();
/// Fetches the list of available cost centers for the current business.
Future<List<CostCenter>> getCostCenters();
/// Creates a new hub.
///
/// Takes the [name] and [address] of the new hub.
@@ -26,7 +29,7 @@ abstract interface class HubRepositoryInterface {
String? street,
String? country,
String? zipCode,
String? costCenter,
String? costCenterId,
});
/// Deletes a hub by its [id].
@@ -52,6 +55,6 @@ abstract interface class HubRepositoryInterface {
String? street,
String? country,
String? zipCode,
String? costCenter,
String? costCenterId,
});
}

View File

@@ -29,6 +29,7 @@ class CreateHubUseCase implements UseCase<CreateHubArguments, Hub> {
street: arguments.street,
country: arguments.country,
zipCode: arguments.zipCode,
costCenterId: arguments.costCenterId,
);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/hub_repository_interface.dart';
/// Usecase to fetch all available cost centers.
class GetCostCentersUseCase {
GetCostCentersUseCase({required HubRepositoryInterface repository})
: _repository = repository;
final HubRepositoryInterface _repository;
Future<List<CostCenter>> call() async {
return _repository.getCostCenters();
}
}

View File

@@ -17,6 +17,7 @@ class UpdateHubArguments extends UseCaseArgument {
this.street,
this.country,
this.zipCode,
this.costCenterId,
});
final String id;
@@ -30,6 +31,7 @@ class UpdateHubArguments extends UseCaseArgument {
final String? street;
final String? country;
final String? zipCode;
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
@@ -44,6 +46,7 @@ class UpdateHubArguments extends UseCaseArgument {
street,
country,
zipCode,
costCenterId,
];
}
@@ -67,6 +70,7 @@ class UpdateHubUseCase implements UseCase<UpdateHubArguments, Hub> {
street: params.street,
country: params.country,
zipCode: params.zipCode,
costCenterId: params.costCenterId,
);
}
}

View File

@@ -1,8 +1,10 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/arguments/create_hub_arguments.dart';
import '../../../domain/usecases/create_hub_usecase.dart';
import '../../../domain/usecases/update_hub_usecase.dart';
import '../../../domain/usecases/get_cost_centers_usecase.dart';
import 'edit_hub_event.dart';
import 'edit_hub_state.dart';
@@ -12,15 +14,36 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
EditHubBloc({
required CreateHubUseCase createHubUseCase,
required UpdateHubUseCase updateHubUseCase,
required GetCostCentersUseCase getCostCentersUseCase,
}) : _createHubUseCase = createHubUseCase,
_updateHubUseCase = updateHubUseCase,
_getCostCentersUseCase = getCostCentersUseCase,
super(const EditHubState()) {
on<EditHubCostCentersLoadRequested>(_onCostCentersLoadRequested);
on<EditHubAddRequested>(_onAddRequested);
on<EditHubUpdateRequested>(_onUpdateRequested);
}
final CreateHubUseCase _createHubUseCase;
final UpdateHubUseCase _updateHubUseCase;
final GetCostCentersUseCase _getCostCentersUseCase;
Future<void> _onCostCentersLoadRequested(
EditHubCostCentersLoadRequested event,
Emitter<EditHubState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
final List<CostCenter> costCenters = await _getCostCentersUseCase.call();
emit(state.copyWith(costCenters: costCenters));
},
onError: (String errorKey) => state.copyWith(
status: EditHubStatus.failure,
errorMessage: errorKey,
),
);
}
Future<void> _onAddRequested(
EditHubAddRequested event,
@@ -43,6 +66,7 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
street: event.street,
country: event.country,
zipCode: event.zipCode,
costCenterId: event.costCenterId,
),
);
emit(
@@ -79,6 +103,7 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
street: event.street,
country: event.country,
zipCode: event.zipCode,
costCenterId: event.costCenterId,
),
);
emit(

View File

@@ -8,6 +8,11 @@ abstract class EditHubEvent extends Equatable {
List<Object?> get props => <Object?>[];
}
/// Event triggered to load all available cost centers.
class EditHubCostCentersLoadRequested extends EditHubEvent {
const EditHubCostCentersLoadRequested();
}
/// Event triggered to add a new hub.
class EditHubAddRequested extends EditHubEvent {
const EditHubAddRequested({
@@ -21,6 +26,7 @@ class EditHubAddRequested extends EditHubEvent {
this.street,
this.country,
this.zipCode,
this.costCenterId,
});
final String name;
@@ -33,6 +39,7 @@ class EditHubAddRequested extends EditHubEvent {
final String? street;
final String? country;
final String? zipCode;
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
@@ -46,6 +53,7 @@ class EditHubAddRequested extends EditHubEvent {
street,
country,
zipCode,
costCenterId,
];
}
@@ -63,6 +71,7 @@ class EditHubUpdateRequested extends EditHubEvent {
this.street,
this.country,
this.zipCode,
this.costCenterId,
});
final String id;
@@ -76,6 +85,7 @@ class EditHubUpdateRequested extends EditHubEvent {
final String? street;
final String? country;
final String? zipCode;
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
@@ -90,5 +100,6 @@ class EditHubUpdateRequested extends EditHubEvent {
street,
country,
zipCode,
costCenterId,
];
}

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Status of the edit hub operation.
enum EditHubStatus {
@@ -21,6 +22,7 @@ class EditHubState extends Equatable {
this.status = EditHubStatus.initial,
this.errorMessage,
this.successMessage,
this.costCenters = const <CostCenter>[],
});
/// The status of the operation.
@@ -32,19 +34,29 @@ class EditHubState extends Equatable {
/// The success message if the operation succeeded.
final String? successMessage;
/// Available cost centers for selection.
final List<CostCenter> costCenters;
/// Create a copy of this state with the given fields replaced.
EditHubState copyWith({
EditHubStatus? status,
String? errorMessage,
String? successMessage,
List<CostCenter>? costCenters,
}) {
return EditHubState(
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
successMessage: successMessage ?? this.successMessage,
costCenters: costCenters ?? this.costCenters,
);
}
@override
List<Object?> get props => <Object?>[status, errorMessage, successMessage];
List<Object?> get props => <Object?>[
status,
errorMessage,
successMessage,
costCenters,
];
}

View File

@@ -57,20 +57,6 @@ class ClientHubsPage extends StatelessWidget {
builder: (BuildContext context, ClientHubsState state) {
return Scaffold(
backgroundColor: UiColors.bgMenu,
floatingActionButton: FloatingActionButton(
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)),
),
child: const Icon(UiIcons.add),
),
body: CustomScrollView(
slivers: <Widget>[
_buildAppBar(context),
@@ -165,20 +151,35 @@ class ClientHubsPage extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_hubs.title,
style: UiTypography.headline1m.white,
),
Text(
t.client_hubs.subtitle,
style: UiTypography.body2r.copyWith(
color: UiColors.switchInactive,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_hubs.title,
style: UiTypography.headline1m.white,
),
),
],
Text(
t.client_hubs.subtitle,
style: UiTypography.body2r.copyWith(
color: UiColors.switchInactive,
),
),
],
),
),
UiButton.primary(
onPressed: () async {
final bool? success = await Modular.to.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
text: t.client_hubs.add_hub,
leadingIcon: UiIcons.add,
size: UiButtonSize.small,
),
],
),

View File

@@ -1,17 +1,15 @@
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/edit_hub/edit_hub_bloc.dart';
import '../blocs/edit_hub/edit_hub_event.dart';
import '../blocs/edit_hub/edit_hub_state.dart';
import '../widgets/edit_hub/edit_hub_form_section.dart';
import '../widgets/hub_form_dialog.dart';
/// A dedicated full-screen page for adding or editing a hub.
/// A wrapper page that shows the hub form in a modal-style layout.
class EditHubPage extends StatefulWidget {
const EditHubPage({this.hub, required this.bloc, super.key});
@@ -23,66 +21,11 @@ class EditHubPage extends StatefulWidget {
}
class _EditHubPageState extends State<EditHubPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _addressController;
late final FocusNode _addressFocusNode;
Prediction? _selectedPrediction;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.hub?.name);
_addressController = TextEditingController(text: widget.hub?.address);
_addressFocusNode = FocusNode();
// Update header on change (if header is added back)
_nameController.addListener(() => setState(() {}));
_addressController.addListener(() => setState(() {}));
}
@override
void dispose() {
_nameController.dispose();
_addressController.dispose();
_addressFocusNode.dispose();
super.dispose();
}
void _onSave() {
if (!_formKey.currentState!.validate()) return;
if (_addressController.text.trim().isEmpty) {
UiSnackbar.show(
context,
message: t.client_hubs.add_hub_dialog.address_hint,
type: UiSnackbarType.error,
);
return;
}
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 ?? ''),
),
);
}
// Load available cost centers
widget.bloc.add(const EditHubCostCentersLoadRequested());
}
@override
@@ -101,7 +44,6 @@ class _EditHubPageState extends State<EditHubPage> {
message: state.successMessage!,
type: UiSnackbarType.success,
);
// Pop back to the previous screen.
Modular.to.pop(true);
}
if (state.status == EditHubStatus.failure &&
@@ -118,42 +60,59 @@ class _EditHubPageState extends State<EditHubPage> {
final bool isSaving = state.status == EditHubStatus.loading;
return Scaffold(
backgroundColor: UiColors.bgMenu,
appBar: UiAppBar(
title: widget.hub == null
? t.client_hubs.add_hub_dialog.title
: t.client_hubs.edit_hub.title,
subtitle: widget.hub == null
? t.client_hubs.add_hub_dialog.create_button
: t.client_hubs.edit_hub.subtitle,
onLeadingPressed: () => Modular.to.pop(),
),
backgroundColor: UiColors.bgOverlay,
body: Stack(
children: <Widget>[
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: EditHubFormSection(
formKey: _formKey,
nameController: _nameController,
addressController: _addressController,
addressFocusNode: _addressFocusNode,
onAddressSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
onSave: _onSave,
isSaving: isSaving,
isEdit: widget.hub != null,
),
),
],
// Tap background to dismiss
GestureDetector(
onTap: () => Modular.to.pop(),
child: Container(color: Colors.transparent),
),
// Dialog-style content centered
Align(
alignment: Alignment.center,
child: HubFormDialog(
hub: widget.hub,
costCenters: state.costCenters,
onCancel: () => Modular.to.pop(),
onSave: ({
required String name,
required String address,
String? costCenterId,
String? placeId,
double? latitude,
double? longitude,
}) {
if (widget.hub == null) {
widget.bloc.add(
EditHubAddRequested(
name: name,
address: address,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
} else {
widget.bloc.add(
EditHubUpdateRequested(
id: widget.hub!.id,
name: name,
address: address,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
}
},
),
),
// ── Loading overlay ──────────────────────────────────────
// Global loading overlay if saving
if (isSaving)
Container(
color: UiColors.black.withValues(alpha: 0.1),

View File

@@ -80,6 +80,15 @@ class HubDetailsPage extends StatelessWidget {
icon: UiIcons.nfc,
isHighlight: hub.nfcTagId != null,
),
const SizedBox(height: UiConstants.space4),
HubDetailsItem(
label: t.client_hubs.hub_details.cost_center_label,
value: hub.costCenter != null
? '${hub.costCenter!.name} (${hub.costCenter!.code})'
: t.client_hubs.hub_details.cost_center_none,
icon: UiIcons.bank, // Using bank icon for cost center
isHighlight: hub.costCenter != null,
),
],
),
),

View File

@@ -2,6 +2,7 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart';
import '../hub_address_autocomplete.dart';
import 'edit_hub_field_label.dart';
@@ -15,6 +16,9 @@ class EditHubFormSection extends StatelessWidget {
required this.addressFocusNode,
required this.onAddressSelected,
required this.onSave,
this.costCenters = const <CostCenter>[],
this.selectedCostCenterId,
required this.onCostCenterChanged,
this.isSaving = false,
this.isEdit = false,
super.key,
@@ -26,6 +30,9 @@ class EditHubFormSection extends StatelessWidget {
final FocusNode addressFocusNode;
final ValueChanged<Prediction> onAddressSelected;
final VoidCallback onSave;
final List<CostCenter> costCenters;
final String? selectedCostCenterId;
final ValueChanged<String?> onCostCenterChanged;
final bool isSaving;
final bool isEdit;
@@ -62,6 +69,51 @@ class EditHubFormSection extends StatelessWidget {
onSelected: onAddressSelected,
),
const SizedBox(height: UiConstants.space4),
EditHubFieldLabel(t.client_hubs.edit_hub.cost_center_label),
InkWell(
onTap: () => _showCostCenterSelector(context),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
decoration: BoxDecoration(
color: UiColors.input,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: selectedCostCenterId != null
? UiColors.ring
: UiColors.border,
width: selectedCostCenterId != null ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
selectedCostCenterId != null
? _getCostCenterName(selectedCostCenterId!)
: t.client_hubs.edit_hub.cost_center_hint,
style: selectedCostCenterId != null
? UiTypography.body1r.textPrimary
: UiTypography.body2r.textPlaceholder,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Icon(
Icons.keyboard_arrow_down,
color: UiColors.iconSecondary,
),
],
),
),
),
const SizedBox(height: UiConstants.space8),
// ── Save button ──────────────────────────────────
@@ -102,4 +154,59 @@ class EditHubFormSection extends StatelessWidget {
),
);
}
String _getCostCenterName(String id) {
try {
final CostCenter cc = costCenters.firstWhere((CostCenter item) => item.id == id);
return cc.code != null ? '${cc.name} (${cc.code})' : cc.name;
} catch (_) {
return id;
}
}
Future<void> _showCostCenterSelector(BuildContext context) async {
final CostCenter? selected = await showDialog<CostCenter>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
title: Text(
t.client_hubs.edit_hub.cost_center_label,
style: UiTypography.headline3m.textPrimary,
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: costCenters.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text('No cost centers available'),
)
: ListView.builder(
shrinkWrap: true,
itemCount: costCenters.length,
itemBuilder: (BuildContext context, int index) {
final CostCenter cc = costCenters[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(cc.name, style: UiTypography.body1m.textPrimary),
subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null,
onTap: () => Navigator.of(context).pop(cc),
);
},
),
),
),
);
},
);
if (selected != null) {
onCostCenterChanged(selected.id);
}
}
}

View File

@@ -11,6 +11,7 @@ class HubAddressAutocomplete extends StatelessWidget {
required this.controller,
required this.hintText,
this.focusNode,
this.decoration,
this.onSelected,
super.key,
});
@@ -18,6 +19,7 @@ class HubAddressAutocomplete extends StatelessWidget {
final TextEditingController controller;
final String hintText;
final FocusNode? focusNode;
final InputDecoration? decoration;
final void Function(Prediction prediction)? onSelected;
@override
@@ -25,6 +27,7 @@ class HubAddressAutocomplete extends StatelessWidget {
return GooglePlaceAutoCompleteTextField(
textEditingController: controller,
focusNode: focusNode,
inputDecoration: decoration ?? const InputDecoration(),
googleAPIKey: AppConfig.googleMapsApiKey,
debounceTime: 500,
countries: HubsConstants.supportedCountries,

View File

@@ -5,25 +5,30 @@ import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart';
import 'hub_address_autocomplete.dart';
import 'edit_hub/edit_hub_field_label.dart';
/// A dialog for adding or editing a hub.
/// A bottom sheet dialog for adding or editing a hub.
class HubFormDialog extends StatefulWidget {
/// Creates a [HubFormDialog].
const HubFormDialog({
required this.onSave,
required this.onCancel,
this.hub,
this.costCenters = const <CostCenter>[],
super.key,
});
/// The hub to edit. If null, a new hub is created.
final Hub? hub;
/// Available cost centers for selection.
final List<CostCenter> costCenters;
/// Callback when the "Save" button is pressed.
final void Function(
String name,
String address, {
final void Function({
required String name,
required String address,
String? costCenterId,
String? placeId,
double? latitude,
double? longitude,
@@ -40,6 +45,7 @@ class _HubFormDialogState extends State<HubFormDialog> {
late final TextEditingController _nameController;
late final TextEditingController _addressController;
late final FocusNode _addressFocusNode;
String? _selectedCostCenterId;
Prediction? _selectedPrediction;
@override
@@ -48,6 +54,7 @@ class _HubFormDialogState extends State<HubFormDialog> {
_nameController = TextEditingController(text: widget.hub?.name);
_addressController = TextEditingController(text: widget.hub?.address);
_addressFocusNode = FocusNode();
_selectedCostCenterId = widget.hub?.costCenter?.id;
}
@override
@@ -63,102 +70,193 @@ class _HubFormDialogState extends State<HubFormDialog> {
@override
Widget build(BuildContext context) {
final bool isEditing = widget.hub != null;
final String title = isEditing
? 'Edit Hub' // TODO: localize
final String title = isEditing
? t.client_hubs.edit_hub.title
: t.client_hubs.add_hub_dialog.title;
final String buttonText = isEditing
? 'Save Changes' // TODO: localize
? t.client_hubs.edit_hub.save_button
: t.client_hubs.add_hub_dialog.create_button;
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),
],
return Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 3),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 10),
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
title,
style: UiTypography.headline3m.textPrimary,
],
),
padding: const EdgeInsets.all(UiConstants.space6),
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
title,
style: UiTypography.headline3m.textPrimary.copyWith(
fontSize: 20,
),
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.space5),
// ── Hub Name ────────────────────────────────
EditHubFieldLabel(t.client_hubs.add_hub_dialog.name_label),
const SizedBox(height: UiConstants.space2),
TextFormField(
controller: _nameController,
style: UiTypography.body1r.textPrimary,
textInputAction: TextInputAction.next,
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return t.client_hubs.add_hub_dialog.name_required;
}
return null;
},
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.name_hint,
),
),
const SizedBox(height: UiConstants.space4),
// ── Cost Center ─────────────────────────────
EditHubFieldLabel(t.client_hubs.add_hub_dialog.cost_center_label),
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: _showCostCenterSelector,
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 16,
),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFD),
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
border: Border.all(
color: _selectedCostCenterId != null
? UiColors.primary
: UiColors.primary.withValues(alpha: 0.1),
width: _selectedCostCenterId != null ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
_selectedCostCenterId != null
? _getCostCenterName(_selectedCostCenterId!)
: t.client_hubs.add_hub_dialog.cost_center_hint,
style: _selectedCostCenterId != null
? UiTypography.body1r.textPrimary
: UiTypography.body2r.textPlaceholder.copyWith(
color: UiColors.textSecondary.withValues(alpha: 0.5),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Icon(
Icons.keyboard_arrow_down,
color: UiColors.iconSecondary,
),
],
),
),
const SizedBox(height: UiConstants.space4),
_buildFieldLabel(t.client_hubs.add_hub_dialog.address_label),
HubAddressAutocomplete(
controller: _addressController,
hintText: t.client_hubs.add_hub_dialog.address_hint,
focusNode: _addressFocusNode,
onSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
),
const SizedBox(height: UiConstants.space4),
// ── Address ─────────────────────────────────
EditHubFieldLabel(t.client_hubs.add_hub_dialog.address_label),
const SizedBox(height: UiConstants.space2),
HubAddressAutocomplete(
controller: _addressController,
hintText: t.client_hubs.add_hub_dialog.address_hint,
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.address_hint,
),
const SizedBox(height: UiConstants.space8),
Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
onPressed: widget.onCancel,
text: t.common.cancel,
focusNode: _addressFocusNode,
onSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
),
const SizedBox(height: UiConstants.space8),
// ── Buttons ─────────────────────────────────
Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: UiColors.primary.withValues(alpha: 0.1),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase * 1.5,
),
),
),
onPressed: widget.onCancel,
text: t.common.cancel,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: UiButton.primary(
onPressed: () {
if (_formKey.currentState!.validate()) {
if (_addressController.text.trim().isEmpty) {
UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error);
return;
}
widget.onSave(
_nameController.text,
_addressController.text,
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(
_selectedPrediction?.lat ?? '',
),
longitude: double.tryParse(
_selectedPrediction?.lng ?? '',
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: UiButton.primary(
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.accent,
foregroundColor: UiColors.accentForeground,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase * 1.5,
),
),
),
onPressed: () {
if (_formKey.currentState!.validate()) {
if (_addressController.text.trim().isEmpty) {
UiSnackbar.show(
context,
message: t.client_hubs.add_hub_dialog.address_required,
type: UiSnackbarType.error,
);
return;
}
},
text: buttonText,
),
widget.onSave(
name: _nameController.text.trim(),
address: _addressController.text.trim(),
costCenterId: _selectedCostCenterId,
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(
_selectedPrediction?.lat ?? '',
),
longitude: double.tryParse(
_selectedPrediction?.lng ?? '',
),
);
}
},
text: buttonText,
),
],
),
],
),
),
],
),
],
),
),
),
@@ -166,35 +264,87 @@ class _HubFormDialogState extends State<HubFormDialog> {
);
}
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,
hintStyle: UiTypography.body2r.textPlaceholder.copyWith(
color: UiColors.textSecondary.withValues(alpha: 0.5),
),
filled: true,
fillColor: UiColors.input,
fillColor: const Color(0xFFF8FAFD),
contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.ring, width: 2),
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
borderSide: const BorderSide(color: UiColors.primary, width: 2),
),
errorStyle: UiTypography.footnote2r.textError,
);
}
String _getCostCenterName(String id) {
try {
return widget.costCenters.firstWhere((CostCenter cc) => cc.id == id).name;
} catch (_) {
return id;
}
}
Future<void> _showCostCenterSelector() async {
final CostCenter? selected = await showDialog<CostCenter>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
title: Text(
t.client_hubs.add_hub_dialog.cost_center_label,
style: UiTypography.headline3m.textPrimary,
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: widget.costCenters.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text('No cost centers available'),
)
: ListView.builder(
shrinkWrap: true,
itemCount: widget.costCenters.length,
itemBuilder: (BuildContext context, int index) {
final CostCenter cc = widget.costCenters[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(cc.name, style: UiTypography.body1m.textPrimary),
subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null,
onTap: () => Navigator.of(context).pop(cc),
);
},
),
),
),
);
},
);
if (selected != null) {
setState(() {
_selectedCostCenterId = selected.id;
});
}
}
}