refactor: Refactor hub form from a dialog to a dedicated widget and streamline HubDetailsPage UI and bloc initialization.
This commit is contained in:
@@ -56,10 +56,8 @@ class ClientHubsModule extends Module {
|
|||||||
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubDetails),
|
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubDetails),
|
||||||
child: (_) {
|
child: (_) {
|
||||||
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
|
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
|
||||||
return HubDetailsPage(
|
final Hub hub = data['hub'] as Hub;
|
||||||
hub: data['hub'] as Hub,
|
return HubDetailsPage(hub: hub);
|
||||||
bloc: Modular.get<HubDetailsBloc>(),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
r.child(
|
r.child(
|
||||||
@@ -67,21 +65,19 @@ class ClientHubsModule extends Module {
|
|||||||
transition: TransitionType.custom,
|
transition: TransitionType.custom,
|
||||||
customTransition: CustomTransition(
|
customTransition: CustomTransition(
|
||||||
opaque: false,
|
opaque: false,
|
||||||
transitionBuilder: (
|
transitionBuilder:
|
||||||
BuildContext context,
|
(
|
||||||
Animation<double> animation,
|
BuildContext context,
|
||||||
Animation<double> secondaryAnimation,
|
Animation<double> animation,
|
||||||
Widget child,
|
Animation<double> secondaryAnimation,
|
||||||
) {
|
Widget child,
|
||||||
return FadeTransition(opacity: animation, child: child);
|
) {
|
||||||
},
|
return FadeTransition(opacity: animation, child: child);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
child: (_) {
|
child: (_) {
|
||||||
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
|
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
|
||||||
return EditHubPage(
|
return EditHubPage(hub: data['hub'] as Hub?);
|
||||||
hub: data['hub'] as Hub?,
|
|
||||||
bloc: Modular.get<EditHubBloc>(),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,32 +8,21 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
import '../blocs/edit_hub/edit_hub_bloc.dart';
|
import '../blocs/edit_hub/edit_hub_bloc.dart';
|
||||||
import '../blocs/edit_hub/edit_hub_event.dart';
|
import '../blocs/edit_hub/edit_hub_event.dart';
|
||||||
import '../blocs/edit_hub/edit_hub_state.dart';
|
import '../blocs/edit_hub/edit_hub_state.dart';
|
||||||
import '../widgets/hub_form_dialog.dart';
|
import '../widgets/hub_form.dart';
|
||||||
|
|
||||||
/// A wrapper page that shows the hub form in a modal-style layout.
|
/// A wrapper page that shows the hub form in a modal-style layout.
|
||||||
class EditHubPage extends StatefulWidget {
|
class EditHubPage extends StatelessWidget {
|
||||||
const EditHubPage({this.hub, required this.bloc, super.key});
|
const EditHubPage({this.hub, super.key});
|
||||||
|
|
||||||
final Hub? hub;
|
final Hub? hub;
|
||||||
final EditHubBloc bloc;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EditHubPage> createState() => _EditHubPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EditHubPageState extends State<EditHubPage> {
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
// Load available cost centers
|
|
||||||
widget.bloc.add(const EditHubCostCentersLoadRequested());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<EditHubBloc>.value(
|
return BlocProvider<EditHubBloc>(
|
||||||
value: widget.bloc,
|
create: (_) =>
|
||||||
child: BlocListener<EditHubBloc, EditHubState>(
|
Modular.get<EditHubBloc>()
|
||||||
|
..add(const EditHubCostCentersLoadRequested()),
|
||||||
|
child: BlocConsumer<EditHubBloc, EditHubState>(
|
||||||
listenWhen: (EditHubState prev, EditHubState curr) =>
|
listenWhen: (EditHubState prev, EditHubState curr) =>
|
||||||
prev.status != curr.status || prev.successKey != curr.successKey,
|
prev.status != curr.status || prev.successKey != curr.successKey,
|
||||||
listener: (BuildContext context, EditHubState state) {
|
listener: (BuildContext context, EditHubState state) {
|
||||||
@@ -58,74 +47,70 @@ class _EditHubPageState extends State<EditHubPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: BlocBuilder<EditHubBloc, EditHubState>(
|
builder: (BuildContext context, EditHubState state) {
|
||||||
builder: (BuildContext context, EditHubState state) {
|
final bool isSaving = state.status == EditHubStatus.loading;
|
||||||
final bool isSaving = state.status == EditHubStatus.loading;
|
final bool isEditing = hub != null;
|
||||||
|
final String title = isEditing
|
||||||
|
? t.client_hubs.edit_hub.title
|
||||||
|
: t.client_hubs.add_hub_dialog.title;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: UiColors.bgOverlay,
|
appBar: UiAppBar(title: title, showBackButton: true),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// Tap background to dismiss
|
SingleChildScrollView(
|
||||||
GestureDetector(
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
onTap: () => Modular.to.pop(),
|
child: HubForm(
|
||||||
child: Container(color: Colors.transparent),
|
hub: hub,
|
||||||
|
costCenters: state.costCenters,
|
||||||
|
onCancel: () => Modular.to.pop(),
|
||||||
|
onSave:
|
||||||
|
({
|
||||||
|
required String name,
|
||||||
|
required String address,
|
||||||
|
String? costCenterId,
|
||||||
|
String? placeId,
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
}) {
|
||||||
|
if (hub == null) {
|
||||||
|
BlocProvider.of<EditHubBloc>(context).add(
|
||||||
|
EditHubAddRequested(
|
||||||
|
name: name,
|
||||||
|
address: address,
|
||||||
|
costCenterId: costCenterId,
|
||||||
|
placeId: placeId,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
BlocProvider.of<EditHubBloc>(context).add(
|
||||||
|
EditHubUpdateRequested(
|
||||||
|
id: hub!.id,
|
||||||
|
name: name,
|
||||||
|
address: address,
|
||||||
|
costCenterId: costCenterId,
|
||||||
|
placeId: placeId,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Dialog-style content centered
|
// Global loading overlay if saving
|
||||||
Align(
|
if (isSaving)
|
||||||
alignment: Alignment.center,
|
Container(
|
||||||
child: HubFormDialog(
|
color: UiColors.black.withValues(alpha: 0.1),
|
||||||
hub: widget.hub,
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
// Global loading overlay if saving
|
),
|
||||||
if (isSaving)
|
);
|
||||||
Container(
|
},
|
||||||
color: UiColors.black.withValues(alpha: 0.1),
|
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,28 +10,27 @@ import '../blocs/hub_details/hub_details_bloc.dart';
|
|||||||
import '../blocs/hub_details/hub_details_event.dart';
|
import '../blocs/hub_details/hub_details_event.dart';
|
||||||
import '../blocs/hub_details/hub_details_state.dart';
|
import '../blocs/hub_details/hub_details_state.dart';
|
||||||
import '../widgets/hub_details/hub_details_bottom_actions.dart';
|
import '../widgets/hub_details/hub_details_bottom_actions.dart';
|
||||||
import '../widgets/hub_details/hub_details_header.dart';
|
|
||||||
import '../widgets/hub_details/hub_details_item.dart';
|
import '../widgets/hub_details/hub_details_item.dart';
|
||||||
|
|
||||||
/// A read-only details page for a single [Hub].
|
/// A read-only details page for a single [Hub].
|
||||||
///
|
///
|
||||||
/// Shows hub name, address, and NFC tag assignment.
|
/// Shows hub name, address, and NFC tag assignment.
|
||||||
class HubDetailsPage extends StatelessWidget {
|
class HubDetailsPage extends StatelessWidget {
|
||||||
const HubDetailsPage({required this.hub, required this.bloc, super.key});
|
const HubDetailsPage({required this.hub, super.key});
|
||||||
|
|
||||||
final Hub hub;
|
final Hub hub;
|
||||||
final HubDetailsBloc bloc;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<HubDetailsBloc>.value(
|
return BlocProvider<HubDetailsBloc>(
|
||||||
value: bloc,
|
create: (_) => Modular.get<HubDetailsBloc>(),
|
||||||
child: BlocListener<HubDetailsBloc, HubDetailsState>(
|
child: BlocListener<HubDetailsBloc, HubDetailsState>(
|
||||||
listener: (BuildContext context, HubDetailsState state) {
|
listener: (BuildContext context, HubDetailsState state) {
|
||||||
if (state.status == HubDetailsStatus.deleted) {
|
if (state.status == HubDetailsStatus.deleted) {
|
||||||
final String message = state.successKey == 'deleted'
|
final String message = state.successKey == 'deleted'
|
||||||
? t.client_hubs.hub_details.deleted_success
|
? t.client_hubs.hub_details.deleted_success
|
||||||
: (state.successMessage ?? t.client_hubs.hub_details.deleted_success);
|
: (state.successMessage ??
|
||||||
|
t.client_hubs.hub_details.deleted_success);
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
message: message,
|
message: message,
|
||||||
@@ -53,23 +52,22 @@ class HubDetailsPage extends StatelessWidget {
|
|||||||
final bool isLoading = state.status == HubDetailsStatus.loading;
|
final bool isLoading = state.status == HubDetailsStatus.loading;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const UiAppBar(showBackButton: true),
|
appBar: UiAppBar(
|
||||||
|
title: hub.name,
|
||||||
|
subtitle: hub.address,
|
||||||
|
showBackButton: true,
|
||||||
|
),
|
||||||
bottomNavigationBar: HubDetailsBottomActions(
|
bottomNavigationBar: HubDetailsBottomActions(
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
onDelete: () => _confirmDeleteHub(context),
|
onDelete: () => _confirmDeleteHub(context),
|
||||||
onEdit: () => _navigateToEditPage(context),
|
onEdit: () => _navigateToEditPage(context),
|
||||||
),
|
),
|
||||||
backgroundColor: UiColors.bgMenu,
|
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
// ── Header ──────────────────────────────────────────
|
|
||||||
HubDetailsHeader(hub: hub),
|
|
||||||
const Divider(height: 1, thickness: 0.5),
|
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -85,11 +83,16 @@ class HubDetailsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
HubDetailsItem(
|
HubDetailsItem(
|
||||||
label: t.client_hubs.hub_details.cost_center_label,
|
label:
|
||||||
|
t.client_hubs.hub_details.cost_center_label,
|
||||||
value: hub.costCenter != null
|
value: hub.costCenter != null
|
||||||
? '${hub.costCenter!.name} (${hub.costCenter!.code})'
|
? '${hub.costCenter!.name} (${hub.costCenter!.code})'
|
||||||
: t.client_hubs.hub_details.cost_center_none,
|
: t
|
||||||
icon: UiIcons.bank, // Using bank icon for cost center
|
.client_hubs
|
||||||
|
.hub_details
|
||||||
|
.cost_center_none,
|
||||||
|
icon: UiIcons
|
||||||
|
.bank, // Using bank icon for cost center
|
||||||
isHighlight: hub.costCenter != null,
|
isHighlight: hub.costCenter != null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -140,7 +143,7 @@ class HubDetailsPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirm == true) {
|
if (confirm == true) {
|
||||||
bloc.add(HubDetailsDeleteRequested(hub.id));
|
Modular.get<HubDetailsBloc>().add(HubDetailsDeleteRequested(hub.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
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 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import 'hub_address_autocomplete.dart';
|
||||||
|
import 'edit_hub/edit_hub_field_label.dart';
|
||||||
|
|
||||||
|
/// A bottom sheet dialog for adding or editing a hub.
|
||||||
|
class HubForm extends StatefulWidget {
|
||||||
|
const HubForm({
|
||||||
|
required this.onSave,
|
||||||
|
required this.onCancel,
|
||||||
|
this.hub,
|
||||||
|
this.costCenters = const <CostCenter>[],
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Hub? hub;
|
||||||
|
final List<CostCenter> costCenters;
|
||||||
|
final void Function({
|
||||||
|
required String name,
|
||||||
|
required String address,
|
||||||
|
String? costCenterId,
|
||||||
|
String? placeId,
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
})
|
||||||
|
onSave;
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HubForm> createState() => _HubFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HubFormState extends State<HubForm> {
|
||||||
|
late final TextEditingController _nameController;
|
||||||
|
late final TextEditingController _addressController;
|
||||||
|
late final FocusNode _addressFocusNode;
|
||||||
|
String? _selectedCostCenterId;
|
||||||
|
Prediction? _selectedPrediction;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nameController = TextEditingController(text: widget.hub?.name);
|
||||||
|
_addressController = TextEditingController(text: widget.hub?.address);
|
||||||
|
_addressFocusNode = FocusNode();
|
||||||
|
_selectedCostCenterId = widget.hub?.costCenter?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_addressController.dispose();
|
||||||
|
_addressFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bool isEditing = widget.hub != null;
|
||||||
|
final String buttonText = isEditing
|
||||||
|
? t.client_hubs.edit_hub.save_button
|
||||||
|
: t.client_hubs.add_hub_dialog.create_button;
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
// ── Hub Name ────────────────────────────────
|
||||||
|
EditHubFieldLabel(t.client_hubs.add_hub_dialog.name_label),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
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),
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
),
|
||||||
|
focusNode: _addressFocusNode,
|
||||||
|
onSelected: (Prediction prediction) {
|
||||||
|
_selectedPrediction = prediction;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: UiConstants.space8),
|
||||||
|
|
||||||
|
// ── Save Button ─────────────────────────────
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: UiButton.primary(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space4),
|
||||||
|
Expanded(
|
||||||
|
child: UiButton.secondary(
|
||||||
|
onPressed: widget.onCancel,
|
||||||
|
text: t.common.cancel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _buildInputDecoration(String hint) {
|
||||||
|
return InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: UiTypography.body2r.textPlaceholder.copyWith(
|
||||||
|
color: UiColors.textSecondary.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFFF8FAFD),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space4,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
|
||||||
|
borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
|
||||||
|
borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
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
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Text(
|
||||||
|
t.client_hubs.add_hub_dialog.cost_centers_empty,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,350 +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 'package:krow_domain/krow_domain.dart';
|
|
||||||
|
|
||||||
import 'hub_address_autocomplete.dart';
|
|
||||||
import 'edit_hub/edit_hub_field_label.dart';
|
|
||||||
|
|
||||||
/// 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({
|
|
||||||
required String name,
|
|
||||||
required String address,
|
|
||||||
String? costCenterId,
|
|
||||||
String? placeId,
|
|
||||||
double? latitude,
|
|
||||||
double? longitude,
|
|
||||||
}) onSave;
|
|
||||||
|
|
||||||
/// Callback when the dialog is cancelled.
|
|
||||||
final VoidCallback onCancel;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<HubFormDialog> createState() => _HubFormDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HubFormDialogState extends State<HubFormDialog> {
|
|
||||||
late final TextEditingController _nameController;
|
|
||||||
late final TextEditingController _addressController;
|
|
||||||
late final FocusNode _addressFocusNode;
|
|
||||||
String? _selectedCostCenterId;
|
|
||||||
Prediction? _selectedPrediction;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_nameController = TextEditingController(text: widget.hub?.name);
|
|
||||||
_addressController = TextEditingController(text: widget.hub?.address);
|
|
||||||
_addressFocusNode = FocusNode();
|
|
||||||
_selectedCostCenterId = widget.hub?.costCenter?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nameController.dispose();
|
|
||||||
_addressController.dispose();
|
|
||||||
_addressFocusNode.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final bool isEditing = widget.hub != null;
|
|
||||||
final String title = isEditing
|
|
||||||
? t.client_hubs.edit_hub.title
|
|
||||||
: t.client_hubs.add_hub_dialog.title;
|
|
||||||
|
|
||||||
final String buttonText = isEditing
|
|
||||||
? t.client_hubs.edit_hub.save_button
|
|
||||||
: t.client_hubs.add_hub_dialog.create_button;
|
|
||||||
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
|
|
||||||
// ── 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),
|
|
||||||
|
|
||||||
// ── 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,
|
|
||||||
),
|
|
||||||
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(
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
InputDecoration _buildInputDecoration(String hint) {
|
|
||||||
return InputDecoration(
|
|
||||||
hintText: hint,
|
|
||||||
hintStyle: UiTypography.body2r.textPlaceholder.copyWith(
|
|
||||||
color: UiColors.textSecondary.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
filled: true,
|
|
||||||
fillColor: const Color(0xFFF8FAFD),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space4,
|
|
||||||
vertical: 16,
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
|
|
||||||
borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
|
|
||||||
borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
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
|
|
||||||
? Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
child: Text(t.client_hubs.add_hub_dialog.cost_centers_empty),
|
|
||||||
)
|
|
||||||
: 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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user