refactor: Extract hub details UI components into dedicated widgets and introduce new edit hub form elements.

This commit is contained in:
Achintha Isuru
2026-02-24 14:22:34 -05:00
parent f30cd89217
commit cd51e8488c
8 changed files with 312 additions and 252 deletions

View File

@@ -9,7 +9,7 @@ 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_address_autocomplete.dart';
import '../widgets/edit_hub/edit_hub_form_section.dart';
/// A dedicated full-screen page for adding or editing a hub.
class EditHubPage extends StatefulWidget {
@@ -36,7 +36,7 @@ class _EditHubPageState extends State<EditHubPage> {
_addressController = TextEditingController(text: widget.hub?.address);
_addressFocusNode = FocusNode();
// Update header on change
// Update header on change (if header is added back)
_nameController.addListener(() => setState(() {}));
_addressController.addListener(() => setState(() {}));
}
@@ -136,59 +136,17 @@ class _EditHubPageState extends State<EditHubPage> {
children: <Widget>[
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// ── Name field ──────────────────────────────────
_FieldLabel(t.client_hubs.edit_hub.name_label),
TextFormField(
controller: _nameController,
style: UiTypography.body1r.textPrimary,
textInputAction: TextInputAction.next,
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
return null;
},
decoration: _inputDecoration(
t.client_hubs.edit_hub.name_hint,
),
),
const SizedBox(height: UiConstants.space4),
// ── Address field ────────────────────────────────
_FieldLabel(
t.client_hubs.edit_hub.address_label,
),
HubAddressAutocomplete(
controller: _addressController,
hintText: t.client_hubs.edit_hub.address_hint,
focusNode: _addressFocusNode,
onSelected: (Prediction prediction) {
child: EditHubFormSection(
formKey: _formKey,
nameController: _nameController,
addressController: _addressController,
addressFocusNode: _addressFocusNode,
onAddressSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
),
const SizedBox(height: UiConstants.space8),
// ── Save button ──────────────────────────────────
UiButton.primary(
onPressed: isSaving ? null : _onSave,
text: widget.hub == null
? t
.client_hubs
.add_hub_dialog
.create_button
: t.client_hubs.edit_hub.save_button,
),
const SizedBox(height: 40),
],
),
onSave: _onSave,
isSaving: isSaving,
isEdit: widget.hub != null,
),
),
],
@@ -209,42 +167,4 @@ class _EditHubPageState extends State<EditHubPage> {
),
);
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: UiTypography.body2r.textPlaceholder,
filled: true,
fillColor: UiColors.input,
contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.ring, width: 2),
),
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(text, style: UiTypography.body2m.textPrimary),
);
}
}

View File

@@ -9,6 +9,9 @@ import 'package:krow_domain/krow_domain.dart';
import '../blocs/hub_details/hub_details_bloc.dart';
import '../blocs/hub_details/hub_details_event.dart';
import '../blocs/hub_details/hub_details_state.dart';
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].
///
@@ -47,49 +50,11 @@ class HubDetailsPage extends StatelessWidget {
final bool isLoading = state.status == HubDetailsStatus.loading;
return Scaffold(
appBar: UiAppBar(
title: t.client_hubs.hub_details.title,
onLeadingPressed: () => Modular.to.pop(),
),
bottomNavigationBar: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Divider(height: 1, thickness: 0.5),
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
onPressed: isLoading
? null
: () => _confirmDeleteHub(context),
text: t.common.delete,
leadingIcon: UiIcons.delete,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: const BorderSide(
color: UiColors.destructive,
),
),
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: UiButton.secondary(
onPressed: isLoading
? null
: () => _navigateToEditPage(context),
text: t.client_hubs.hub_details.edit_button,
leadingIcon: UiIcons.edit,
),
),
],
),
),
],
),
appBar: const UiAppBar(showBackButton: true),
bottomNavigationBar: HubDetailsBottomActions(
isLoading: isLoading,
onDelete: () => _confirmDeleteHub(context),
onEdit: () => _navigateToEditPage(context),
),
backgroundColor: UiColors.bgMenu,
body: Stack(
@@ -99,75 +64,7 @@ class HubDetailsPage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// ── Header ──────────────────────────────────────────
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Container(
width: 114,
decoration: BoxDecoration(
color: UiColors.primary.withValues(
alpha: 0.08,
),
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
border: Border.all(color: UiColors.primary),
),
child: const Center(
child: Icon(
UiIcons.nfc,
color: UiColors.primary,
size: 32,
),
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
hub.name,
style:
UiTypography.headline1b.textPrimary,
),
const SizedBox(
height: UiConstants.space1,
),
Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 16,
color: UiColors.textSecondary,
),
const SizedBox(
width: UiConstants.space1,
),
Expanded(
child: Text(
hub.address,
style: UiTypography
.body2r
.textSecondary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
],
),
),
),
HubDetailsHeader(hub: hub),
const Divider(height: 1, thickness: 0.5),
Padding(
@@ -175,7 +72,7 @@ class HubDetailsPage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_buildDetailItem(
HubDetailsItem(
label: t.client_hubs.hub_details.nfc_label,
value:
hub.nfcTagId ??
@@ -203,51 +100,6 @@ class HubDetailsPage extends StatelessWidget {
);
}
Widget _buildDetailItem({
required String label,
required String value,
required IconData icon,
bool isHighlight = false,
}) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: isHighlight
? UiColors.tagInProgress
: UiColors.bgInputField,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Icon(
icon,
color: isHighlight ? UiColors.iconSuccess : UiColors.iconThird,
size: 20,
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(label, style: UiTypography.footnote1r.textSecondary),
const SizedBox(height: UiConstants.space1),
Text(value, style: UiTypography.body1m.textPrimary),
],
),
),
],
),
);
}
Future<void> _navigateToEditPage(BuildContext context) async {
final bool? saved = await Modular.to.toEditHub(hub: hub);
if (saved == true && context.mounted) {

View File

@@ -0,0 +1,17 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A simple field label widget for the edit hub page.
class EditHubFieldLabel extends StatelessWidget {
const EditHubFieldLabel(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(text, style: UiTypography.body2m.textPrimary),
);
}
}

View File

@@ -0,0 +1,105 @@
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 '../hub_address_autocomplete.dart';
import 'edit_hub_field_label.dart';
/// The form section for adding or editing a hub.
class EditHubFormSection extends StatelessWidget {
const EditHubFormSection({
required this.formKey,
required this.nameController,
required this.addressController,
required this.addressFocusNode,
required this.onAddressSelected,
required this.onSave,
this.isSaving = false,
this.isEdit = false,
super.key,
});
final GlobalKey<FormState> formKey;
final TextEditingController nameController;
final TextEditingController addressController;
final FocusNode addressFocusNode;
final ValueChanged<Prediction> onAddressSelected;
final VoidCallback onSave;
final bool isSaving;
final bool isEdit;
@override
Widget build(BuildContext context) {
return Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// ── Name field ──────────────────────────────────
EditHubFieldLabel(t.client_hubs.edit_hub.name_label),
TextFormField(
controller: nameController,
style: UiTypography.body1r.textPrimary,
textInputAction: TextInputAction.next,
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
return null;
},
decoration: _inputDecoration(t.client_hubs.edit_hub.name_hint),
),
const SizedBox(height: UiConstants.space4),
// ── Address field ────────────────────────────────
EditHubFieldLabel(t.client_hubs.edit_hub.address_label),
HubAddressAutocomplete(
controller: addressController,
hintText: t.client_hubs.edit_hub.address_hint,
focusNode: addressFocusNode,
onSelected: onAddressSelected,
),
const SizedBox(height: UiConstants.space8),
// ── Save button ──────────────────────────────────
UiButton.primary(
onPressed: isSaving ? null : onSave,
text: isEdit
? t.client_hubs.edit_hub.save_button
: t.client_hubs.add_hub_dialog.create_button,
),
const SizedBox(height: 40),
],
),
);
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: UiTypography.body2r.textPlaceholder,
filled: true,
fillColor: UiColors.input,
contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
borderSide: const BorderSide(color: UiColors.ring, width: 2),
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Bottom action buttons for the hub details page.
class HubDetailsBottomActions extends StatelessWidget {
const HubDetailsBottomActions({
required this.onDelete,
required this.onEdit,
this.isLoading = false,
super.key,
});
final VoidCallback onDelete;
final VoidCallback onEdit;
final bool isLoading;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Divider(height: 1, thickness: 0.5),
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
onPressed: isLoading ? null : onDelete,
text: t.common.delete,
leadingIcon: UiIcons.delete,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: const BorderSide(color: UiColors.destructive),
),
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: UiButton.secondary(
onPressed: isLoading ? null : onEdit,
text: t.client_hubs.hub_details.edit_button,
leadingIcon: UiIcons.edit,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// Header widget for the hub details page.
class HubDetailsHeader extends StatelessWidget {
const HubDetailsHeader({required this.hub, super.key});
final Hub hub;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
spacing: UiConstants.space1,
children: <Widget>[
Text(hub.name, style: UiTypography.headline1b.textPrimary),
Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 16,
color: UiColors.textSecondary,
),
const SizedBox(width: UiConstants.space1),
Expanded(
child: Text(
hub.address,
style: UiTypography.body2r.textSecondary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A reusable detail item for the hub details page.
class HubDetailsItem extends StatelessWidget {
const HubDetailsItem({
required this.label,
required this.value,
required this.icon,
this.isHighlight = false,
super.key,
});
final String label;
final String value;
final IconData icon;
final bool isHighlight;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: isHighlight
? UiColors.tagInProgress
: UiColors.bgInputField,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Icon(
icon,
color: isHighlight ? UiColors.iconSuccess : UiColors.iconThird,
size: 20,
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(label, style: UiTypography.footnote1r.textSecondary),
const SizedBox(height: UiConstants.space1),
Text(value, style: UiTypography.body1m.textPrimary),
],
),
),
],
),
);
}
}

View File

@@ -21,7 +21,9 @@ class OnboardingSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
final TranslationsStaffProfileEn i18n = Translations.of(
context,
).staff.profile;
return BlocBuilder<ProfileCubit, ProfileState>(
builder: (BuildContext context, ProfileState state) {
@@ -49,6 +51,11 @@ class OnboardingSection extends StatelessWidget {
completed: state.experienceComplete,
onTap: () => Modular.to.toExperience(),
),
ProfileMenuItem(
icon: UiIcons.shirt,
label: i18n.menu_items.attire,
onTap: () => Modular.to.toAttire(),
),
],
),
],