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),
|
||||
child: (_) {
|
||||
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
|
||||
return HubDetailsPage(
|
||||
hub: data['hub'] as Hub,
|
||||
bloc: Modular.get<HubDetailsBloc>(),
|
||||
);
|
||||
final Hub hub = data['hub'] as Hub;
|
||||
return HubDetailsPage(hub: hub);
|
||||
},
|
||||
);
|
||||
r.child(
|
||||
@@ -67,21 +65,19 @@ class ClientHubsModule extends Module {
|
||||
transition: TransitionType.custom,
|
||||
customTransition: CustomTransition(
|
||||
opaque: false,
|
||||
transitionBuilder: (
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
transitionBuilder:
|
||||
(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
),
|
||||
child: (_) {
|
||||
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
|
||||
return EditHubPage(
|
||||
hub: data['hub'] as Hub?,
|
||||
bloc: Modular.get<EditHubBloc>(),
|
||||
);
|
||||
return EditHubPage(hub: data['hub'] as Hub?);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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_event.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.
|
||||
class EditHubPage extends StatefulWidget {
|
||||
const EditHubPage({this.hub, required this.bloc, super.key});
|
||||
class EditHubPage extends StatelessWidget {
|
||||
const EditHubPage({this.hub, super.key});
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<EditHubBloc>.value(
|
||||
value: widget.bloc,
|
||||
child: BlocListener<EditHubBloc, EditHubState>(
|
||||
return BlocProvider<EditHubBloc>(
|
||||
create: (_) =>
|
||||
Modular.get<EditHubBloc>()
|
||||
..add(const EditHubCostCentersLoadRequested()),
|
||||
child: BlocConsumer<EditHubBloc, EditHubState>(
|
||||
listenWhen: (EditHubState prev, EditHubState curr) =>
|
||||
prev.status != curr.status || prev.successKey != curr.successKey,
|
||||
listener: (BuildContext context, EditHubState state) {
|
||||
@@ -58,74 +47,70 @@ class _EditHubPageState extends State<EditHubPage> {
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<EditHubBloc, EditHubState>(
|
||||
builder: (BuildContext context, EditHubState state) {
|
||||
final bool isSaving = state.status == EditHubStatus.loading;
|
||||
builder: (BuildContext context, EditHubState state) {
|
||||
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(
|
||||
backgroundColor: UiColors.bgOverlay,
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(title: title, showBackButton: true),
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: HubForm(
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Global loading overlay if saving
|
||||
if (isSaving)
|
||||
Container(
|
||||
color: UiColors.black.withValues(alpha: 0.1),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// 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_state.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';
|
||||
|
||||
/// A read-only details page for a single [Hub].
|
||||
///
|
||||
/// Shows hub name, address, and NFC tag assignment.
|
||||
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 HubDetailsBloc bloc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<HubDetailsBloc>.value(
|
||||
value: bloc,
|
||||
return BlocProvider<HubDetailsBloc>(
|
||||
create: (_) => Modular.get<HubDetailsBloc>(),
|
||||
child: BlocListener<HubDetailsBloc, HubDetailsState>(
|
||||
listener: (BuildContext context, HubDetailsState state) {
|
||||
if (state.status == HubDetailsStatus.deleted) {
|
||||
final String message = state.successKey == 'deleted'
|
||||
? 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(
|
||||
context,
|
||||
message: message,
|
||||
@@ -53,23 +52,22 @@ class HubDetailsPage extends StatelessWidget {
|
||||
final bool isLoading = state.status == HubDetailsStatus.loading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: const UiAppBar(showBackButton: true),
|
||||
appBar: UiAppBar(
|
||||
title: hub.name,
|
||||
subtitle: hub.address,
|
||||
showBackButton: true,
|
||||
),
|
||||
bottomNavigationBar: HubDetailsBottomActions(
|
||||
isLoading: isLoading,
|
||||
onDelete: () => _confirmDeleteHub(context),
|
||||
onEdit: () => _navigateToEditPage(context),
|
||||
),
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
// ── Header ──────────────────────────────────────────
|
||||
HubDetailsHeader(hub: hub),
|
||||
const Divider(height: 1, thickness: 0.5),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
@@ -85,11 +83,16 @@ class HubDetailsPage extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
HubDetailsItem(
|
||||
label: t.client_hubs.hub_details.cost_center_label,
|
||||
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
|
||||
: t
|
||||
.client_hubs
|
||||
.hub_details
|
||||
.cost_center_none,
|
||||
icon: UiIcons
|
||||
.bank, // Using bank icon for cost center
|
||||
isHighlight: hub.costCenter != null,
|
||||
),
|
||||
],
|
||||
@@ -140,7 +143,7 @@ class HubDetailsPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
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