finalcommitform4

This commit is contained in:
2026-02-19 16:09:54 +05:30
parent da8f9a4436
commit 9e9eb0f374
21 changed files with 799 additions and 234 deletions

View File

@@ -9,6 +9,7 @@ import 'src/domain/usecases/assign_nfc_tag_usecase.dart';
import 'src/domain/usecases/create_hub_usecase.dart';
import 'src/domain/usecases/delete_hub_usecase.dart';
import 'src/domain/usecases/get_hubs_usecase.dart';
import 'src/domain/usecases/update_hub_usecase.dart';
import 'src/presentation/blocs/client_hubs_bloc.dart';
import 'src/presentation/pages/client_hubs_page.dart';
@@ -29,6 +30,7 @@ class ClientHubsModule extends Module {
i.addLazySingleton(CreateHubUseCase.new);
i.addLazySingleton(DeleteHubUseCase.new);
i.addLazySingleton(AssignNfcTagUseCase.new);
i.addLazySingleton(UpdateHubUseCase.new);
// BLoCs
i.add<ClientHubsBloc>(ClientHubsBloc.new);

View File

@@ -124,6 +124,78 @@ class HubRepositoryImpl implements HubRepositoryInterface {
);
}
@override
Future<domain.Hub> updateHub({
required String id,
String? name,
String? address,
String? placeId,
double? latitude,
double? longitude,
String? city,
String? state,
String? street,
String? country,
String? zipCode,
}) async {
return _service.run(() async {
final _PlaceAddress? placeAddress =
placeId == null || placeId.isEmpty
? null
: await _fetchPlaceAddress(placeId);
final dc.UpdateTeamHubVariablesBuilder builder = _service.connector
.updateTeamHub(id: id);
if (name != null) builder.hubName(name);
if (address != null) builder.address(address);
if (placeId != null || placeAddress != null) {
builder.placeId(placeId ?? placeAddress?.street);
}
if (latitude != null) builder.latitude(latitude);
if (longitude != null) builder.longitude(longitude);
if (city != null || placeAddress?.city != null) {
builder.city(city ?? placeAddress?.city);
}
if (state != null || placeAddress?.state != null) {
builder.state(state ?? placeAddress?.state);
}
if (street != null || placeAddress?.street != null) {
builder.street(street ?? placeAddress?.street);
}
if (country != null || placeAddress?.country != null) {
builder.country(country ?? placeAddress?.country);
}
if (zipCode != null || placeAddress?.zipCode != null) {
builder.zipCode(zipCode ?? placeAddress?.zipCode);
}
await builder.execute();
final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business);
final List<domain.Hub> hubs = await _fetchHubsForTeam(
teamId: teamId,
businessId: business.id,
);
for (final domain.Hub hub in hubs) {
if (hub.id == id) return hub;
}
// Fallback: return a reconstructed Hub from the update inputs.
return domain.Hub(
id: id,
businessId: business.id,
name: name ?? '',
address: address ?? '',
nfcTagId: null,
status: domain.HubStatus.active,
);
});
}
Future<dc.GetBusinessesByUserIdBusinesses>
_getBusinessForCurrentUser() async {
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;

View File

@@ -35,4 +35,21 @@ abstract interface class HubRepositoryInterface {
///
/// Takes the [hubId] and the [nfcTagId] to be associated.
Future<void> assignNfcTag({required String hubId, required String nfcTagId});
/// Updates an existing hub by its [id].
///
/// All fields other than [id] are optional — only supplied values are updated.
Future<Hub> updateHub({
required String id,
String? name,
String? address,
String? placeId,
double? latitude,
double? longitude,
String? city,
String? state,
String? street,
String? country,
String? zipCode,
});
}

View File

@@ -1,10 +1,10 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/hub_repository_interface.dart';
import '../../domain/arguments/create_hub_arguments.dart';
/// Arguments for the UpdateHubUseCase.
class UpdateHubArguments {
class UpdateHubArguments extends UseCaseArgument {
const UpdateHubArguments({
required this.id,
this.name,
@@ -30,10 +30,25 @@ class UpdateHubArguments {
final String? street;
final String? country;
final String? zipCode;
@override
List<Object?> get props => <Object?>[
id,
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
];
}
/// Use case for updating an existing hub.
class UpdateHubUseCase implements UseCase<Future<Hub>, UpdateHubArguments> {
class UpdateHubUseCase implements UseCase<UpdateHubArguments, Hub> {
UpdateHubUseCase(this.repository);
final HubRepositoryInterface repository;

View File

@@ -9,6 +9,7 @@ import '../../domain/usecases/assign_nfc_tag_usecase.dart';
import '../../domain/usecases/create_hub_usecase.dart';
import '../../domain/usecases/delete_hub_usecase.dart';
import '../../domain/usecases/get_hubs_usecase.dart';
import '../../domain/usecases/update_hub_usecase.dart';
import 'client_hubs_event.dart';
import 'client_hubs_state.dart';
@@ -25,13 +26,16 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
required CreateHubUseCase createHubUseCase,
required DeleteHubUseCase deleteHubUseCase,
required AssignNfcTagUseCase assignNfcTagUseCase,
required UpdateHubUseCase updateHubUseCase,
}) : _getHubsUseCase = getHubsUseCase,
_createHubUseCase = createHubUseCase,
_deleteHubUseCase = deleteHubUseCase,
_assignNfcTagUseCase = assignNfcTagUseCase,
_updateHubUseCase = updateHubUseCase,
super(const ClientHubsState()) {
on<ClientHubsFetched>(_onFetched);
on<ClientHubsAddRequested>(_onAddRequested);
on<ClientHubsUpdateRequested>(_onUpdateRequested);
on<ClientHubsDeleteRequested>(_onDeleteRequested);
on<ClientHubsNfcTagAssignRequested>(_onNfcTagAssignRequested);
on<ClientHubsMessageCleared>(_onMessageCleared);
@@ -42,6 +46,7 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
final CreateHubUseCase _createHubUseCase;
final DeleteHubUseCase _deleteHubUseCase;
final AssignNfcTagUseCase _assignNfcTagUseCase;
final UpdateHubUseCase _updateHubUseCase;
void _onAddDialogToggled(
ClientHubsAddDialogToggled event,
@@ -120,6 +125,46 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
);
}
Future<void> _onUpdateRequested(
ClientHubsUpdateRequested event,
Emitter<ClientHubsState> emit,
) async {
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
await handleError(
emit: emit,
action: () async {
await _updateHubUseCase(
UpdateHubArguments(
id: event.id,
name: event.name,
address: event.address,
placeId: event.placeId,
latitude: event.latitude,
longitude: event.longitude,
city: event.city,
state: event.state,
street: event.street,
country: event.country,
zipCode: event.zipCode,
),
);
final List<Hub> hubs = await _getHubsUseCase();
emit(
state.copyWith(
status: ClientHubsStatus.actionSuccess,
hubs: hubs,
successMessage: 'Hub updated successfully!',
),
);
},
onError: (String errorKey) => state.copyWith(
status: ClientHubsStatus.actionFailure,
errorMessage: errorKey,
),
);
}
Future<void> _onDeleteRequested(
ClientHubsDeleteRequested event,
Emitter<ClientHubsState> emit,

View File

@@ -55,6 +55,50 @@ class ClientHubsAddRequested extends ClientHubsEvent {
];
}
/// Event triggered to update an existing hub.
class ClientHubsUpdateRequested extends ClientHubsEvent {
const ClientHubsUpdateRequested({
required this.id,
required this.name,
required this.address,
this.placeId,
this.latitude,
this.longitude,
this.city,
this.state,
this.street,
this.country,
this.zipCode,
});
final String id;
final String name;
final String address;
final String? placeId;
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? street;
final String? country;
final String? zipCode;
@override
List<Object?> get props => <Object?>[
id,
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
];
}
/// Event triggered to delete a hub.
class ClientHubsDeleteRequested extends ClientHubsEvent {

View File

@@ -0,0 +1,240 @@
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:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart';
import '../blocs/client_hubs_event.dart';
import '../blocs/client_hubs_state.dart';
import '../widgets/hub_address_autocomplete.dart';
/// A dedicated full-screen page for editing an existing hub.
///
/// Takes the parent [ClientHubsBloc] via [BlocProvider.value] so the
/// updated hub list is reflected on the hubs list page when the user
/// saves and navigates back.
class EditHubPage extends StatefulWidget {
const EditHubPage({
required this.hub,
required this.bloc,
super.key,
});
final Hub hub;
final ClientHubsBloc bloc;
@override
State<EditHubPage> createState() => _EditHubPageState();
}
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();
}
@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;
}
context.read<ClientHubsBloc>().add(
ClientHubsUpdateRequested(
id: widget.hub.id,
name: _nameController.text.trim(),
address: _addressController.text.trim(),
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider<ClientHubsBloc>.value(
value: widget.bloc,
child: BlocListener<ClientHubsBloc, ClientHubsState>(
listenWhen: (ClientHubsState prev, ClientHubsState curr) =>
prev.status != curr.status || prev.successMessage != curr.successMessage,
listener: (BuildContext context, ClientHubsState state) {
if (state.status == ClientHubsStatus.actionSuccess &&
state.successMessage != null) {
UiSnackbar.show(
context,
message: state.successMessage!,
type: UiSnackbarType.success,
);
// Pop back to details page with updated hub
Navigator.of(context).pop(true);
}
if (state.status == ClientHubsStatus.actionFailure &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: state.errorMessage!,
type: UiSnackbarType.error,
);
}
},
child: BlocBuilder<ClientHubsBloc, ClientHubsState>(
builder: (BuildContext context, ClientHubsState state) {
final bool isSaving =
state.status == ClientHubsStatus.actionInProgress;
return Scaffold(
backgroundColor: UiColors.bgMenu,
appBar: AppBar(
backgroundColor: UiColors.foreground,
leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
onPressed: () => Navigator.of(context).pop(),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_hubs.edit_hub.title,
style: UiTypography.headline3m.white,
),
Text(
t.client_hubs.edit_hub.subtitle,
style: UiTypography.footnote1r.copyWith(
color: UiColors.white.withValues(alpha: 0.7),
),
),
],
),
),
body: Stack(
children: <Widget>[
SingleChildScrollView(
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) {
_selectedPrediction = prediction;
},
),
const SizedBox(height: UiConstants.space8),
// ── Save button ──────────────────────────────────
UiButton.primary(
onPressed: isSaving ? null : _onSave,
text: t.client_hubs.edit_hub.save_button,
),
const SizedBox(height: 40),
],
),
),
),
// ── Loading overlay ──────────────────────────────────────
if (isSaving)
Container(
color: UiColors.black.withValues(alpha: 0.1),
child: const Center(child: CircularProgressIndicator()),
),
],
),
);
},
),
),
);
}
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

@@ -1,12 +1,16 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart';
import '../blocs/client_hubs_event.dart';
import '../widgets/hub_form_dialog.dart';
import '../blocs/client_hubs_bloc.dart';
import 'edit_hub_page.dart';
/// A read-only details page for a single [Hub].
///
/// Shows hub name, address, and NFC tag assignment.
/// Tapping the edit button navigates to [EditHubPage] (a dedicated page,
/// not a dialog), satisfying the "separate edit hub page" acceptance criterion.
class HubDetailsPage extends StatelessWidget {
const HubDetailsPage({
required this.hub,
@@ -19,50 +23,51 @@ class HubDetailsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider<ClientHubsBloc>.value(
value: bloc,
child: Scaffold(
appBar: AppBar(
title: Text(hub.name),
backgroundColor: UiColors.foreground,
leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
onPressed: () => Modular.to.pop(),
return Scaffold(
appBar: AppBar(
title: Text(hub.name),
backgroundColor: UiColors.foreground,
leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
onPressed: () => Navigator.of(context).pop(),
),
actions: <Widget>[
TextButton.icon(
onPressed: () => _navigateToEditPage(context),
icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16),
label: Text(
t.client_hubs.hub_details.edit_button,
style: const TextStyle(color: UiColors.white),
),
),
actions: [
IconButton(
icon: const Icon(UiIcons.edit, color: UiColors.white),
onPressed: () => _showEditDialog(context),
],
),
backgroundColor: UiColors.bgMenu,
body: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildDetailItem(
label: t.client_hubs.hub_details.name_label,
value: hub.name,
icon: UiIcons.home,
),
const SizedBox(height: UiConstants.space4),
_buildDetailItem(
label: t.client_hubs.hub_details.address_label,
value: hub.address,
icon: UiIcons.mapPin,
),
const SizedBox(height: UiConstants.space4),
_buildDetailItem(
label: t.client_hubs.hub_details.nfc_label,
value: hub.nfcTagId ?? t.client_hubs.hub_details.nfc_not_assigned,
icon: UiIcons.nfc,
isHighlight: hub.nfcTagId != null,
),
],
),
backgroundColor: UiColors.bgMenu,
body: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailItem(
label: 'Name',
value: hub.name,
icon: UiIcons.home,
),
const SizedBox(height: UiConstants.space4),
_buildDetailItem(
label: 'Address',
value: hub.address,
icon: UiIcons.mapPin,
),
const SizedBox(height: UiConstants.space4),
_buildDetailItem(
label: 'NFC Tag',
value: hub.nfcTagId ?? 'Not Assigned',
icon: UiIcons.nfc,
isHighlight: hub.nfcTagId != null,
),
],
),
),
),
);
}
@@ -78,7 +83,7 @@ class HubDetailsPage extends StatelessWidget {
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
boxShadow: const [
boxShadow: const <BoxShadow>[
BoxShadow(
color: UiColors.popupShadow,
blurRadius: 10,
@@ -87,11 +92,11 @@ class HubDetailsPage extends StatelessWidget {
],
),
child: Row(
children: [
children: <Widget>[
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: isHighlight ? UiColors.tagInProgress : UiColors.bgInput,
color: isHighlight ? UiColors.tagInProgress : UiColors.bgInputField,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Icon(
@@ -104,16 +109,10 @@ class HubDetailsPage extends StatelessWidget {
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: UiTypography.footnote1r.textSecondary,
),
children: <Widget>[
Text(label, style: UiTypography.footnote1r.textSecondary),
const SizedBox(height: UiConstants.space1),
Text(
value,
style: UiTypography.body1m.textPrimary,
),
Text(value, style: UiTypography.body1m.textPrimary),
],
),
),
@@ -122,33 +121,17 @@ class HubDetailsPage extends StatelessWidget {
);
}
void _showEditDialog(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => HubFormDialog(
hub: hub,
onSave: (name, address, {placeId, latitude, longitude, city, state, street, country, zipCode}) {
bloc.add(
ClientHubsUpdateRequested(
id: hub.id,
name: name,
address: address,
placeId: placeId,
latitude: latitude,
longitude: longitude,
city: city,
state: state,
street: street,
country: country,
zipCode: zipCode,
),
);
Navigator.of(context).pop(); // Close dialog
Navigator.of(context).pop(); // Go back to list to refresh
},
onCancel: () => Navigator.of(context).pop(),
Future<void> _navigateToEditPage(BuildContext context) async {
// Navigate to the dedicated edit page and await result.
// If the page returns `true` (save succeeded), pop the details page too so
// the user sees the refreshed hub list (the BLoC already holds updated data).
final bool? saved = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => EditHubPage(hub: hub, bloc: bloc),
),
);
if (saved == true && context.mounted) {
Navigator.of(context).pop();
}
}
}