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

@@ -239,6 +239,24 @@
"address_hint": "Full address",
"create_button": "Create Hub"
},
"edit_hub": {
"title": "Edit Hub",
"subtitle": "Update hub details",
"name_label": "Hub Name *",
"name_hint": "e.g., Main Kitchen, Front Desk",
"address_label": "Address",
"address_hint": "Full address",
"save_button": "Save Changes",
"success": "Hub updated successfully!"
},
"hub_details": {
"title": "Hub Details",
"name_label": "Name",
"address_label": "Address",
"nfc_label": "NFC Tag",
"nfc_not_assigned": "Not Assigned",
"edit_button": "Edit Hub"
},
"nfc_dialog": {
"title": "Identify NFC Tag",
"instruction": "Tap your phone to the NFC tag to identify it",
@@ -1154,6 +1172,7 @@
"success": {
"hub": {
"created": "Hub created successfully!",
"updated": "Hub updated successfully!",
"deleted": "Hub deleted successfully!",
"nfc_assigned": "NFC tag assigned successfully!"
},

View File

@@ -253,6 +253,24 @@
"dependency_warning": "Ten en cuenta que si hay turnos/órdenes asignados a este hub no deberíamos poder eliminarlo.",
"cancel": "Cancelar",
"delete": "Eliminar"
},
"edit_hub": {
"title": "Editar Hub",
"subtitle": "Actualizar detalles del hub",
"name_label": "Nombre del Hub",
"name_hint": "Ingresar nombre del hub",
"address_label": "Dirección",
"address_hint": "Ingresar dirección",
"save_button": "Guardar Cambios",
"success": "¡Hub actualizado exitosamente!"
},
"hub_details": {
"title": "Detalles del Hub",
"edit_button": "Editar",
"name_label": "Nombre del Hub",
"address_label": "Dirección",
"nfc_label": "Etiqueta NFC",
"nfc_not_assigned": "No asignada"
}
},
"client_create_order": {
@@ -1154,6 +1172,7 @@
"success": {
"hub": {
"created": "¡Hub creado exitosamente!",
"updated": "¡Hub actualizado exitosamente!",
"deleted": "¡Hub eliminado exitosamente!",
"nfc_assigned": "¡Etiqueta NFC asignada exitosamente!"
},

View File

@@ -1,23 +1,13 @@
import 'package:equatable/equatable.dart';
<<<<<<< Updated upstream
import 'recurring_order_position.dart';
/// Represents a recurring staffing request spanning a date range.
=======
import 'one_time_order.dart';
import 'one_time_order_position.dart';
/// Represents a customer's request for recurring staffing.
>>>>>>> Stashed changes
class RecurringOrder extends Equatable {
const RecurringOrder({
required this.startDate,
required this.endDate,
required this.recurringDays,
<<<<<<< Updated upstream
required this.location,
=======
>>>>>>> Stashed changes
required this.positions,
this.hub,
this.eventName,
@@ -25,7 +15,6 @@ class RecurringOrder extends Equatable {
this.roleRates = const <String, double>{},
});
<<<<<<< Updated upstream
/// Start date for the recurring schedule.
final DateTime startDate;
@@ -59,25 +48,6 @@ class RecurringOrder extends Equatable {
endDate,
recurringDays,
location,
=======
final DateTime startDate;
final DateTime endDate;
/// List of days (e.g., ['Monday', 'Wednesday']) or bitmask.
final List<String> recurringDays;
final List<OneTimeOrderPosition> positions;
final OneTimeOrderHubDetails? hub;
final String? eventName;
final String? vendorId;
final Map<String, double> roleRates;
@override
List<Object?> get props => [
startDate,
endDate,
recurringDays,
>>>>>>> Stashed changes
positions,
hub,
eventName,
@@ -85,7 +55,6 @@ class RecurringOrder extends Equatable {
roleRates,
];
}
<<<<<<< Updated upstream
/// Minimal hub details used during recurring order creation.
class RecurringOrderHubDetails extends Equatable {
@@ -130,5 +99,3 @@ class RecurringOrderHubDetails extends Equatable {
zipCode,
];
}
=======
>>>>>>> Stashed changes

View File

@@ -179,7 +179,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
.date(orderTimestamp)
.startDate(startTimestamp)
.endDate(endTimestamp)
.recurringDays(fdc.AnyValue(order.recurringDays))
.recurringDays(order.recurringDays)
.execute();
final String orderId = orderResult.data.order_insert.id;
@@ -274,7 +274,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
if (vendorId == null || vendorId.isEmpty) {
throw Exception('Vendor is missing.');
}
final domain.PermanentOrderHubDetails? hub = order.hub;
final domain.OneTimeOrderHubDetails? hub = order.hub;
if (hub == null || hub.id.isEmpty) {
throw Exception('Hub is missing.');
}
@@ -299,7 +299,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
.status(dc.OrderStatus.POSTED)
.date(orderTimestamp)
.startDate(startTimestamp)
.permanentDays(fdc.AnyValue(order.permanentDays))
.permanentDays(order.permanentDays)
.execute();
final String orderId = orderResult.data.order_insert.id;
@@ -311,7 +311,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
final Set<String> selectedDays = Set<String>.from(order.permanentDays);
final int workersNeeded = order.positions.fold<int>(
0,
(int sum, domain.PermanentOrderPosition position) => sum + position.count,
(int sum, domain.OneTimeOrderPosition position) => sum + position.count,
);
final double shiftCost = _calculatePermanentShiftCost(order);
@@ -352,7 +352,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
final String shiftId = shiftResult.data.shift_insert.id;
shiftIds.add(shiftId);
for (final domain.PermanentOrderPosition position in order.positions) {
for (final domain.OneTimeOrderPosition position in order.positions) {
final DateTime start = _parseTime(day, position.startTime);
final DateTime end = _parseTime(day, position.endTime);
final DateTime normalizedEnd =
@@ -420,7 +420,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte
double _calculatePermanentShiftCost(domain.PermanentOrder order) {
double total = 0;
for (final domain.PermanentOrderPosition position in order.positions) {
for (final domain.OneTimeOrderPosition position in order.positions) {
final DateTime start = _parseTime(order.startDate, position.startTime);
final DateTime end = _parseTime(order.startDate, position.endTime);
final DateTime normalizedEnd =

View File

@@ -3,7 +3,7 @@ import 'package:krow_domain/krow_domain.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Use case for creating a permanent staffing order.
class CreatePermanentOrderUseCase implements UseCase<Future<void>, PermanentOrder> {
class CreatePermanentOrderUseCase implements UseCase<PermanentOrder, void> {
const CreatePermanentOrderUseCase(this._repository);
final ClientCreateOrderRepositoryInterface _repository;

View File

@@ -3,7 +3,7 @@ import 'package:krow_domain/krow_domain.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Use case for creating a recurring staffing order.
class CreateRecurringOrderUseCase implements UseCase<Future<void>, RecurringOrder> {
class CreateRecurringOrderUseCase implements UseCase<RecurringOrder, void> {
const CreateRecurringOrderUseCase(this._repository);
final ClientCreateOrderRepositoryInterface _repository;

View File

@@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/arguments/permanent_order_arguments.dart';
import '../../domain/usecases/create_permanent_order_usecase.dart';
import 'permanent_order_event.dart';
import 'permanent_order_state.dart';
@@ -286,10 +285,9 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
final domain.PermanentOrder order = domain.PermanentOrder(
startDate: state.startDate,
permanentDays: state.permanentDays,
location: selectedHub.name,
positions: state.positions
.map(
(PermanentOrderPosition p) => domain.PermanentOrderPosition(
(PermanentOrderPosition p) => domain.OneTimeOrderPosition(
role: p.role,
count: p.count,
startTime: p.startTime,
@@ -299,7 +297,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
),
)
.toList(),
hub: domain.PermanentOrderHubDetails(
hub: domain.OneTimeOrderHubDetails(
id: selectedHub.id,
name: selectedHub.name,
address: selectedHub.address,
@@ -316,9 +314,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
vendorId: state.selectedVendor?.id,
roleRates: roleRates,
);
await _createPermanentOrderUseCase(
PermanentOrderArguments(order: order),
);
await _createPermanentOrderUseCase(order);
emit(state.copyWith(status: PermanentOrderStatus.success));
},
onError: (String errorKey) => state.copyWith(

View File

@@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/arguments/recurring_order_arguments.dart';
import '../../domain/usecases/create_recurring_order_usecase.dart';
import 'recurring_order_event.dart';
import 'recurring_order_state.dart';
@@ -334,9 +333,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
vendorId: state.selectedVendor?.id,
roleRates: roleRates,
);
await _createRecurringOrderUseCase(
RecurringOrderArguments(order: order),
);
await _createRecurringOrderUseCase(order);
emit(state.copyWith(status: RecurringOrderStatus.success));
},
onError: (String errorKey) => state.copyWith(

View File

@@ -26,7 +26,7 @@ class ClientHomeEditBanner extends StatelessWidget {
builder: (BuildContext context, ClientHomeState state) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: state.isEditMode ? 76 : 0,
height: state.isEditMode ? 80 : 0,
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
@@ -43,21 +43,23 @@ class ClientHomeEditBanner extends StatelessWidget {
children: <Widget>[
const Icon(UiIcons.edit, size: 16, color: UiColors.primary),
const SizedBox(width: UiConstants.space2),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
i18n.dashboard.edit_mode_active,
style: UiTypography.footnote1b.copyWith(
color: UiColors.primary,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
i18n.dashboard.edit_mode_active,
style: UiTypography.footnote1b.copyWith(
color: UiColors.primary,
),
),
),
Text(
i18n.dashboard.drag_instruction,
style: UiTypography.footnote2r.textSecondary,
),
],
Text(
i18n.dashboard.drag_instruction,
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
UiButton.secondary(
text: i18n.dashboard.reset,

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();
}
}
}

View File

@@ -52,7 +52,14 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
bottom: 32,
),
decoration: const BoxDecoration(
color: Color(0xFF1A1A2E),
gradient: LinearGradient(
colors: [
UiColors.primary,
UiColors.buttonPrimaryHover,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,

View File

@@ -42,6 +42,7 @@ class ClientSettingsPage extends StatelessWidget {
}
},
child: const Scaffold(
backgroundColor: UiColors.bgMenu,
body: CustomScrollView(
slivers: <Widget>[
SettingsProfileHeader(),

View File

@@ -14,27 +14,46 @@ class SettingsActions extends StatelessWidget {
@override
/// Builds the settings actions UI.
Widget build(BuildContext context) {
// Get the translations for the client settings profile.
final TranslationsClientSettingsProfileEn labels =
t.client_settings.profile;
// Yellow button style matching the prototype
final ButtonStyle yellowStyle = ElevatedButton.styleFrom(
backgroundColor: UiColors.accent,
foregroundColor: UiColors.accentForeground,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
);
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
sliver: SliverList(
delegate: SliverChildListDelegate(<Widget>[
const SizedBox(height: UiConstants.space5),
/// TODO: FEATURE_NOT_YET_IMPLEMENTED
// Edit profile is not yet implemented
// Edit Profile button (yellow)
UiButton.primary(
text: labels.edit_profile,
style: yellowStyle,
onPressed: () {},
),
const SizedBox(height: UiConstants.space4),
// Hubs button
// Hubs button (yellow)
UiButton.primary(
text: labels.hubs,
style: yellowStyle,
onPressed: () => Modular.to.toClientHubs(),
),
const SizedBox(height: UiConstants.space4),
// Log out button
// Quick Links card
_QuickLinksCard(labels: labels),
const SizedBox(height: UiConstants.space4),
// Log Out button (outlined)
BlocBuilder<ClientSettingsBloc, ClientSettingsState>(
builder: (BuildContext context, ClientSettingsState state) {
return UiButton.secondary(
@@ -45,17 +64,11 @@ class SettingsActions extends StatelessWidget {
);
},
),
const SizedBox(height: UiConstants.space8),
]),
),
);
}
/// Handles the sign-out button click event.
void _onSignoutClicked(BuildContext context) {
ReadContext(
context,
).read<ClientSettingsBloc>().add(const ClientSettingsSignOutRequested());
}
/// Shows a confirmation dialog for signing out.
Future<void> _showSignOutDialog(BuildContext context) {
@@ -74,13 +87,10 @@ class SettingsActions extends StatelessWidget {
style: UiTypography.body2r.textSecondary,
),
actions: <Widget>[
// Log out button
UiButton.secondary(
text: t.client_settings.profile.log_out,
onPressed: () => _onSignoutClicked(context),
),
// Cancel button
UiButton.secondary(
text: t.common.cancel,
onPressed: () => Modular.to.pop(),
@@ -89,4 +99,97 @@ class SettingsActions extends StatelessWidget {
),
);
}
/// Handles the sign-out button click event.
void _onSignoutClicked(BuildContext context) {
ReadContext(context)
.read<ClientSettingsBloc>()
.add(const ClientSettingsSignOutRequested());
}
}
/// Quick Links card — inline here since it's always part of SettingsActions ordering.
class _QuickLinksCard extends StatelessWidget {
final TranslationsClientSettingsProfileEn labels;
const _QuickLinksCard({required this.labels});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
side: const BorderSide(color: UiColors.border),
),
color: UiColors.white,
child: Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
labels.quick_links,
style: UiTypography.footnote1b.textPrimary,
),
const SizedBox(height: UiConstants.space3),
_QuickLinkItem(
icon: UiIcons.nfc,
title: labels.clock_in_hubs,
onTap: () => Modular.to.toClientHubs(),
),
_QuickLinkItem(
icon: UiIcons.building,
title: labels.billing_payments,
onTap: () => Modular.to.toClientBilling(),
),
],
),
),
);
}
}
/// A single quick link row item.
class _QuickLinkItem extends StatelessWidget {
final IconData icon;
final String title;
final VoidCallback onTap;
const _QuickLinkItem({
required this.icon,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: UiConstants.radiusMd,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space3,
horizontal: UiConstants.space2,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
Icon(icon, size: 20, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space3),
Text(title, style: UiTypography.footnote1m.textPrimary),
],
),
const Icon(
UiIcons.chevronRight,
size: 20,
color: UiColors.iconThird,
),
],
),
),
);
}
}

View File

@@ -11,7 +11,6 @@ class SettingsProfileHeader extends StatelessWidget {
const SettingsProfileHeader({super.key});
@override
/// Builds the profile header UI.
Widget build(BuildContext context) {
final TranslationsClientSettingsProfileEn labels = t.client_settings.profile;
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
@@ -23,78 +22,115 @@ class SettingsProfileHeader extends StatelessWidget {
? businessName.trim()[0].toUpperCase()
: 'C';
return SliverAppBar(
backgroundColor: UiColors.bgSecondary,
expandedHeight: 140,
pinned: true,
elevation: 0,
shape: const Border(bottom: BorderSide(color: UiColors.border, width: 1)),
leading: IconButton(
icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary),
onPressed: () => Modular.to.toClientHome(),
),
flexibleSpace: FlexibleSpaceBar(
background: Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space8),
margin: const EdgeInsets.only(top: UiConstants.space24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
spacing: UiConstants.space4,
children: <Widget>[
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: UiColors.border, width: 2),
color: UiColors.white,
return SliverToBoxAdapter(
child: Container(
width: double.infinity,
padding: const EdgeInsets.only(bottom: 36),
decoration: const BoxDecoration(
color: UiColors.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
// ── Top bar: back arrow + title ──────────────────
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space2,
),
child: CircleAvatar(
backgroundColor: UiColors.primary.withValues(alpha: 0.1),
backgroundImage:
photoUrl != null && photoUrl.isNotEmpty
? NetworkImage(photoUrl)
: null,
child:
photoUrl != null && photoUrl.isNotEmpty
? null
: Text(
avatarLetter,
style: UiTypography.headline1m.copyWith(
color: UiColors.primary,
),
),
child: Row(
children: <Widget>[
GestureDetector(
onTap: () => Modular.to.toClientHome(),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 22,
),
),
const SizedBox(width: UiConstants.space3),
Text(
labels.title,
style: UiTypography.body1b.copyWith(
color: UiColors.white,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(businessName, style: UiTypography.body1b.textPrimary),
const SizedBox(height: UiConstants.space1),
Row(
mainAxisAlignment: MainAxisAlignment.start,
spacing: UiConstants.space1,
children: <Widget>[
Icon(
UiIcons.mail,
size: 14,
color: UiColors.textSecondary,
),
Text(
email,
style: UiTypography.footnote1r.textSecondary,
),
],
),
const SizedBox(height: UiConstants.space6),
// ── Avatar ───────────────────────────────────────
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: UiColors.white,
border: Border.all(
color: UiColors.white.withValues(alpha: 0.6),
width: 3,
),
boxShadow: [
BoxShadow(
color: UiColors.black.withValues(alpha: 0.15),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
],
),
child: ClipOval(
child: photoUrl != null && photoUrl.isNotEmpty
? Image.network(photoUrl, fit: BoxFit.cover)
: Center(
child: Text(
avatarLetter,
style: UiTypography.headline1m.copyWith(
color: UiColors.primary,
fontSize: 32,
),
),
),
),
),
const SizedBox(height: UiConstants.space4),
// ── Business Name ─────────────────────────────────
Text(
businessName,
style: UiTypography.headline3m.copyWith(
color: UiColors.white,
),
),
const SizedBox(height: UiConstants.space2),
// ── Email ─────────────────────────────────────────
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
UiIcons.mail,
size: 14,
color: UiColors.white.withValues(alpha: 0.75),
),
const SizedBox(width: 6),
Text(
email,
style: UiTypography.footnote1r.copyWith(
color: UiColors.white.withValues(alpha: 0.75),
),
),
],
),
],
),
),
title: Text(labels.title, style: UiTypography.body1b.textPrimary),
);
}
}