feat: Implement client hubs management feature, including CRUD operations and NFC tag assignment.

This commit is contained in:
Achintha Isuru
2026-01-21 19:50:16 -05:00
parent 78917a5f84
commit 12dfde0551
27 changed files with 1670 additions and 5 deletions

View File

@@ -0,0 +1,49 @@
library client_hubs;
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'src/data/repositories_impl/hub_repository_impl.dart';
import 'src/domain/repositories/hub_repository_interface.dart';
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/presentation/blocs/client_hubs_bloc.dart';
import 'src/presentation/pages/client_hubs_page.dart';
export 'src/presentation/pages/client_hubs_page.dart';
/// A [Module] for the client hubs feature.
class ClientHubsModule extends Module {
@override
List<Module> get imports => [DataConnectModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<HubRepositoryInterface>(
() => HubRepositoryImpl(mock: i.get<BusinessRepositoryMock>()),
);
// UseCases
i.addLazySingleton(GetHubsUseCase.new);
i.addLazySingleton(CreateHubUseCase.new);
i.addLazySingleton(DeleteHubUseCase.new);
i.addLazySingleton(AssignNfcTagUseCase.new);
// BLoCs
i.add<ClientHubsBloc>(
() => ClientHubsBloc(
getHubsUseCase: i.get<GetHubsUseCase>(),
createHubUseCase: i.get<CreateHubUseCase>(),
deleteHubUseCase: i.get<DeleteHubUseCase>(),
assignNfcTagUseCase: i.get<AssignNfcTagUseCase>(),
),
);
}
@override
void routes(RouteManager r) {
r.child('/', child: (_) => const ClientHubsPage());
}
}

View File

@@ -0,0 +1,37 @@
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/hub_repository_interface.dart';
/// Implementation of [HubRepositoryInterface].
///
/// This implementation delegates data access to the [BusinessRepositoryMock]
/// from the `data_connect` package.
class HubRepositoryImpl implements HubRepositoryInterface {
/// The business repository mock from data connect.
final BusinessRepositoryMock mock;
/// Creates a [HubRepositoryImpl] with the required [mock].
HubRepositoryImpl({required this.mock});
@override
Future<List<Hub>> getHubs() {
// In a real app, we would get the business ID from a session or state.
// For this prototype/mock, we use a hardcoded value.
return mock.getHubs('biz_1');
}
@override
Future<Hub> createHub({required String name, required String address}) {
return mock.createHub(businessId: 'biz_1', name: name, address: address);
}
@override
Future<void> deleteHub(String id) {
return mock.deleteHub(id);
}
@override
Future<void> assignNfcTag({required String hubId, required String nfcTagId}) {
return mock.assignNfcTag(hubId: hubId, nfcTagId: nfcTagId);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:krow_core/core.dart';
/// Arguments for creating a new hub.
class CreateHubArguments extends UseCaseArgument {
final String name;
final String address;
const CreateHubArguments({required this.name, required this.address});
@override
List<Object?> get props => [name, address];
}
/// Arguments for assigning an NFC tag to a hub.
class AssignNfcTagArguments extends UseCaseArgument {
final String hubId;
final String nfcTagId;
const AssignNfcTagArguments({required this.hubId, required this.nfcTagId});
@override
List<Object?> get props => [hubId, nfcTagId];
}
/// Arguments for deleting a hub.
class DeleteHubArguments extends UseCaseArgument {
final String hubId;
const DeleteHubArguments({required this.hubId});
@override
List<Object?> get props => [hubId];
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_domain/krow_domain.dart';
/// Interface for the Hub repository.
///
/// This repository handles hub-related operations such as
/// fetching, creating, deleting hubs and assigning NFC tags.
abstract interface class HubRepositoryInterface {
/// Fetches the list of hubs for the current client.
Future<List<Hub>> getHubs();
/// Creates a new hub.
Future<Hub> createHub({required String name, required String address});
/// Deletes a hub by its [id].
Future<void> deleteHub(String id);
/// Assigns an NFC tag to a hub.
Future<void> assignNfcTag({required String hubId, required String nfcTagId});
}

View File

@@ -0,0 +1,18 @@
import 'package:krow_core/core.dart';
import '../arguments/hub_arguments.dart';
import '../repositories/hub_repository_interface.dart';
/// Use case for assigning an NFC tag to a hub.
class AssignNfcTagUseCase implements UseCase<AssignNfcTagArguments, void> {
final HubRepositoryInterface _repository;
AssignNfcTagUseCase(this._repository);
@override
Future<void> call(AssignNfcTagArguments arguments) {
return _repository.assignNfcTag(
hubId: arguments.hubId,
nfcTagId: arguments.nfcTagId,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/hub_arguments.dart';
import '../repositories/hub_repository_interface.dart';
/// Use case for creating a new hub.
class CreateHubUseCase implements UseCase<CreateHubArguments, Hub> {
final HubRepositoryInterface _repository;
CreateHubUseCase(this._repository);
@override
Future<Hub> call(CreateHubArguments arguments) {
return _repository.createHub(
name: arguments.name,
address: arguments.address,
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:krow_core/core.dart';
import '../arguments/hub_arguments.dart';
import '../repositories/hub_repository_interface.dart';
/// Use case for deleting a hub.
class DeleteHubUseCase implements UseCase<DeleteHubArguments, void> {
final HubRepositoryInterface _repository;
DeleteHubUseCase(this._repository);
@override
Future<void> call(DeleteHubArguments arguments) {
return _repository.deleteHub(arguments.hubId);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/hub_repository_interface.dart';
/// Use case for fetching the list of hubs.
class GetHubsUseCase implements NoInputUseCase<List<Hub>> {
final HubRepositoryInterface _repository;
GetHubsUseCase(this._repository);
@override
Future<List<Hub>> call() {
return _repository.getHubs();
}
}

View File

@@ -0,0 +1,154 @@
import 'package:bloc/bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../../domain/arguments/hub_arguments.dart';
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 'client_hubs_event.dart';
import 'client_hubs_state.dart';
/// BLoC responsible for managing client hubs.
class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
implements Disposable {
final GetHubsUseCase _getHubsUseCase;
final CreateHubUseCase _createHubUseCase;
final DeleteHubUseCase _deleteHubUseCase;
final AssignNfcTagUseCase _assignNfcTagUseCase;
ClientHubsBloc({
required GetHubsUseCase getHubsUseCase,
required CreateHubUseCase createHubUseCase,
required DeleteHubUseCase deleteHubUseCase,
required AssignNfcTagUseCase assignNfcTagUseCase,
}) : _getHubsUseCase = getHubsUseCase,
_createHubUseCase = createHubUseCase,
_deleteHubUseCase = deleteHubUseCase,
_assignNfcTagUseCase = assignNfcTagUseCase,
super(const ClientHubsState()) {
on<ClientHubsFetched>(_onFetched);
on<ClientHubsAddRequested>(_onAddRequested);
on<ClientHubsDeleteRequested>(_onDeleteRequested);
on<ClientHubsNfcTagAssignRequested>(_onNfcTagAssignRequested);
on<ClientHubsMessageCleared>(_onMessageCleared);
}
Future<void> _onFetched(
ClientHubsFetched event,
Emitter<ClientHubsState> emit,
) async {
emit(state.copyWith(status: ClientHubsStatus.loading));
try {
final hubs = await _getHubsUseCase();
emit(state.copyWith(status: ClientHubsStatus.success, hubs: hubs));
} catch (e) {
emit(
state.copyWith(
status: ClientHubsStatus.failure,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onAddRequested(
ClientHubsAddRequested event,
Emitter<ClientHubsState> emit,
) async {
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
try {
await _createHubUseCase(
CreateHubArguments(name: event.name, address: event.address),
);
final hubs = await _getHubsUseCase();
emit(
state.copyWith(
status: ClientHubsStatus.actionSuccess,
hubs: hubs,
successMessage: 'Hub created successfully',
),
);
} catch (e) {
emit(
state.copyWith(
status: ClientHubsStatus.actionFailure,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onDeleteRequested(
ClientHubsDeleteRequested event,
Emitter<ClientHubsState> emit,
) async {
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
try {
await _deleteHubUseCase(DeleteHubArguments(hubId: event.hubId));
final hubs = await _getHubsUseCase();
emit(
state.copyWith(
status: ClientHubsStatus.actionSuccess,
hubs: hubs,
successMessage: 'Hub deleted successfully',
),
);
} catch (e) {
emit(
state.copyWith(
status: ClientHubsStatus.actionFailure,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onNfcTagAssignRequested(
ClientHubsNfcTagAssignRequested event,
Emitter<ClientHubsState> emit,
) async {
emit(state.copyWith(status: ClientHubsStatus.actionInProgress));
try {
await _assignNfcTagUseCase(
AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId),
);
final hubs = await _getHubsUseCase();
emit(
state.copyWith(
status: ClientHubsStatus.actionSuccess,
hubs: hubs,
successMessage: 'NFC tag assigned successfully',
),
);
} catch (e) {
emit(
state.copyWith(
status: ClientHubsStatus.actionFailure,
errorMessage: e.toString(),
),
);
}
}
void _onMessageCleared(
ClientHubsMessageCleared event,
Emitter<ClientHubsState> emit,
) {
emit(
state.copyWith(
errorMessage: null,
successMessage: null,
status:
state.status == ClientHubsStatus.actionSuccess ||
state.status == ClientHubsStatus.actionFailure
? ClientHubsStatus.success
: state.status,
),
);
}
@override
void dispose() {
close();
}
}

View File

@@ -0,0 +1,54 @@
import 'package:equatable/equatable.dart';
/// Base class for all client hubs events.
abstract class ClientHubsEvent extends Equatable {
const ClientHubsEvent();
@override
List<Object?> get props => [];
}
/// Event triggered to fetch the list of hubs.
class ClientHubsFetched extends ClientHubsEvent {
const ClientHubsFetched();
}
/// Event triggered to add a new hub.
class ClientHubsAddRequested extends ClientHubsEvent {
final String name;
final String address;
const ClientHubsAddRequested({required this.name, required this.address});
@override
List<Object?> get props => [name, address];
}
/// Event triggered to delete a hub.
class ClientHubsDeleteRequested extends ClientHubsEvent {
final String hubId;
const ClientHubsDeleteRequested(this.hubId);
@override
List<Object?> get props => [hubId];
}
/// Event triggered to assign an NFC tag to a hub.
class ClientHubsNfcTagAssignRequested extends ClientHubsEvent {
final String hubId;
final String nfcTagId;
const ClientHubsNfcTagAssignRequested({
required this.hubId,
required this.nfcTagId,
});
@override
List<Object?> get props => [hubId, nfcTagId];
}
/// Event triggered to clear any error or success messages.
class ClientHubsMessageCleared extends ClientHubsEvent {
const ClientHubsMessageCleared();
}

View File

@@ -0,0 +1,45 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Enum representing the status of the client hubs state.
enum ClientHubsStatus {
initial,
loading,
success,
failure,
actionInProgress,
actionSuccess,
actionFailure,
}
/// State class for the ClientHubs BLoC.
class ClientHubsState extends Equatable {
final ClientHubsStatus status;
final List<Hub> hubs;
final String? errorMessage;
final String? successMessage;
const ClientHubsState({
this.status = ClientHubsStatus.initial,
this.hubs = const [],
this.errorMessage,
this.successMessage,
});
ClientHubsState copyWith({
ClientHubsStatus? status,
List<Hub>? hubs,
String? errorMessage,
String? successMessage,
}) {
return ClientHubsState(
status: status ?? this.status,
hubs: hubs ?? this.hubs,
errorMessage: errorMessage ?? this.errorMessage,
successMessage: successMessage ?? this.successMessage,
);
}
@override
List<Object?> get props => [status, hubs, errorMessage, successMessage];
}

View File

@@ -0,0 +1,9 @@
import 'package:flutter_modular/flutter_modular.dart';
/// Extension on [IModularNavigator] to provide typed navigation for client hubs.
extension ClientHubsNavigator on IModularNavigator {
/// Navigates to the client hubs page.
Future<void> pushClientHubs() async {
await pushNamed('/client/hubs/');
}
}

View File

@@ -0,0 +1,202 @@
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:lucide_icons/lucide_icons.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import '../blocs/client_hubs_bloc.dart';
import '../blocs/client_hubs_event.dart';
import '../blocs/client_hubs_state.dart';
import '../widgets/add_hub_dialog.dart';
import '../widgets/hub_card.dart';
import '../widgets/hub_empty_state.dart';
import '../widgets/hub_info_card.dart';
import '../widgets/identify_nfc_dialog.dart';
/// The main page for the client hubs feature.
class ClientHubsPage extends StatefulWidget {
/// Creates a [ClientHubsPage].
const ClientHubsPage({super.key});
@override
State<ClientHubsPage> createState() => _ClientHubsPageState();
}
class _ClientHubsPageState extends State<ClientHubsPage> {
bool _showAddHub = false;
Hub? _hubToIdentify;
@override
Widget build(BuildContext context) {
return BlocProvider<ClientHubsBloc>(
create: (context) =>
Modular.get<ClientHubsBloc>()..add(const ClientHubsFetched()),
child: BlocConsumer<ClientHubsBloc, ClientHubsState>(
listener: (context, state) {
if (state.errorMessage != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.errorMessage!)));
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsMessageCleared());
}
if (state.successMessage != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.successMessage!)));
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsMessageCleared());
}
},
builder: (context, state) {
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC), // slate-50
body: Stack(
children: [
CustomScrollView(
slivers: [
_buildAppBar(context),
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 100),
sliver: SliverList(
delegate: SliverChildListDelegate([
if (state.status == ClientHubsStatus.loading)
const Center(child: CircularProgressIndicator())
else if (state.hubs.isEmpty)
HubEmptyState(
onAddPressed: () =>
setState(() => _showAddHub = true),
)
else ...[
...state.hubs.map(
(hub) => HubCard(
hub: hub,
onNfcPressed: () =>
setState(() => _hubToIdentify = hub),
onDeletePressed: () =>
BlocProvider.of<ClientHubsBloc>(
context,
).add(ClientHubsDeleteRequested(hub.id)),
),
),
],
const SizedBox(height: 20),
const HubInfoCard(),
]),
),
),
],
),
if (_showAddHub)
AddHubDialog(
onCreate: (name, address) {
BlocProvider.of<ClientHubsBloc>(context).add(
ClientHubsAddRequested(name: name, address: address),
);
setState(() => _showAddHub = false);
},
onCancel: () => setState(() => _showAddHub = false),
),
if (_hubToIdentify != null)
IdentifyNfcDialog(
hub: _hubToIdentify!,
onAssign: (tagId) {
BlocProvider.of<ClientHubsBloc>(context).add(
ClientHubsNfcTagAssignRequested(
hubId: _hubToIdentify!.id,
nfcTagId: tagId,
),
);
setState(() => _hubToIdentify = null);
},
onCancel: () => setState(() => _hubToIdentify = null),
),
if (state.status == ClientHubsStatus.actionInProgress)
Container(
color: Colors.black.withOpacity(0.1),
child: const Center(child: CircularProgressIndicator()),
),
],
),
);
},
),
);
}
Widget _buildAppBar(BuildContext context) {
return SliverAppBar(
backgroundColor: const Color(0xFF121826),
automaticallyImplyLeading: false,
expandedHeight: 140,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF121826), Color(0xFF1E293B)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
padding: const EdgeInsets.fromLTRB(20, 48, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => Modular.to.pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.arrowLeft,
color: Colors.white,
size: 20,
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.client_hubs.title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
Text(
t.client_hubs.subtitle,
style: const TextStyle(
color: Color(0xFFCBD5E1), // slate-300
fontSize: 14,
),
),
],
),
UiButton.primary(
onPressed: () => setState(() => _showAddHub = true),
text: t.client_hubs.add_hub,
leadingIcon: LucideIcons.plus,
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,154 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart';
/// A dialog for adding a new hub.
class AddHubDialog extends StatefulWidget {
/// Callback when the "Create Hub" button is pressed.
final Function(String name, String address) onCreate;
/// Callback when the dialog is cancelled.
final VoidCallback onCancel;
/// Creates an [AddHubDialog].
const AddHubDialog({
required this.onCreate,
required this.onCancel,
super.key,
});
@override
State<AddHubDialog> createState() => _AddHubDialogState();
}
class _AddHubDialogState extends State<AddHubDialog> {
late final TextEditingController _nameController;
late final TextEditingController _addressController;
@override
void initState() {
super.initState();
_nameController = TextEditingController();
_addressController = TextEditingController();
}
@override
void dispose() {
_nameController.dispose();
_addressController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black.withOpacity(0.5),
child: Center(
child: SingleChildScrollView(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 20),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.client_hubs.add_hub_dialog.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 24),
_buildFieldLabel(t.client_hubs.add_hub_dialog.name_label),
TextField(
controller: _nameController,
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.name_hint,
),
),
const SizedBox(height: 16),
_buildFieldLabel(t.client_hubs.add_hub_dialog.address_label),
TextField(
controller: _addressController,
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.address_hint,
),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: UiButton.secondary(
onPressed: widget.onCancel,
text: t.common.cancel,
),
),
const SizedBox(width: 12),
Expanded(
child: UiButton.primary(
onPressed: () {
if (_nameController.text.isNotEmpty) {
widget.onCreate(
_nameController.text,
_addressController.text,
);
}
},
text: t.client_hubs.add_hub_dialog.create_button,
),
),
],
),
],
),
),
),
),
);
}
Widget _buildFieldLabel(String label) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF0F172A),
),
),
);
}
InputDecoration _buildInputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 14),
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF2563EB), width: 2),
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:core_localization/core_localization.dart';
/// A card displaying information about a single hub.
class HubCard extends StatelessWidget {
/// The hub to display.
final Hub hub;
/// Callback when the NFC button is pressed.
final VoidCallback onNfcPressed;
/// Callback when the delete button is pressed.
final VoidCallback onDeletePressed;
/// Creates a [HubCard].
const HubCard({
required this.hub,
required this.onNfcPressed,
required this.onDeletePressed,
super.key,
});
@override
Widget build(BuildContext context) {
final bool hasNfc = hub.nfcTagId != null;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF), // blue-50
borderRadius: BorderRadius.circular(16),
),
child: Icon(
hasNfc ? LucideIcons.checkCircle : LucideIcons.nfc,
color: hasNfc
? const Color(0xFF16A34A)
: const Color(0xFF94A3B8), // green-600 or slate-400
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
hub.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Color(0xFF0F172A),
),
),
if (hub.address.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
const Icon(
LucideIcons.mapPin,
size: 12,
color: Color(0xFF94A3B8),
),
const SizedBox(width: 4),
Expanded(
child: Text(
hub.address,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (hasNfc)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!),
style: const TextStyle(
color: Color(0xFF16A34A),
fontSize: 12,
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
),
),
),
],
),
),
Row(
children: [
IconButton(
onPressed: onNfcPressed,
icon: const Icon(
LucideIcons.nfc,
color: Color(0xFF2563EB),
size: 20,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
const SizedBox(width: 8),
IconButton(
onPressed: onDeletePressed,
icon: const Icon(
LucideIcons.trash2,
color: Color(0xFFDC2626),
size: 20,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:core_localization/core_localization.dart';
/// Widget displayed when there are no hubs.
class HubEmptyState extends StatelessWidget {
/// Callback when the add button is pressed.
final VoidCallback onAddPressed;
/// Creates a [HubEmptyState].
const HubEmptyState({required this.onAddPressed, super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
color: Color(0xFFF1F5F9), // slate-100
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.nfc,
size: 32,
color: Color(0xFF94A3B8),
),
),
const SizedBox(height: 16),
Text(
t.client_hubs.empty_state.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 8),
Text(
t.client_hubs.empty_state.description,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B), fontSize: 14),
),
const SizedBox(height: 24),
UiButton.primary(
onPressed: onAddPressed,
text: t.client_hubs.empty_state.button,
leadingIcon: LucideIcons.plus,
),
],
),
);
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:core_localization/core_localization.dart';
/// A card with information about how hubs work.
class HubInfoCard extends StatelessWidget {
/// Creates a [HubInfoCard].
const HubInfoCard({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF), // blue-50
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(LucideIcons.nfc, size: 20, color: Color(0xFF2563EB)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.client_hubs.about_hubs.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 4),
Text(
t.client_hubs.about_hubs.description,
style: const TextStyle(
color: Color(0xFF334155),
fontSize: 12,
height: 1.4,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,186 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
/// A dialog for identifying and assigning an NFC tag to a hub.
class IdentifyNfcDialog extends StatefulWidget {
/// The hub to assign the tag to.
final Hub hub;
/// Callback when a tag is assigned.
final Function(String nfcTagId) onAssign;
/// Callback when the dialog is cancelled.
final VoidCallback onCancel;
/// Creates an [IdentifyNfcDialog].
const IdentifyNfcDialog({
required this.hub,
required this.onAssign,
required this.onCancel,
super.key,
});
@override
State<IdentifyNfcDialog> createState() => _IdentifyNfcDialogState();
}
class _IdentifyNfcDialogState extends State<IdentifyNfcDialog> {
String? _nfcTagId;
void _simulateNFCScan() {
setState(() {
_nfcTagId =
'NFC-${DateTime.now().millisecondsSinceEpoch.toString().substring(8).toUpperCase()}';
});
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black.withOpacity(0.5),
child: Center(
child: SingleChildScrollView(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 20),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
t.client_hubs.nfc_dialog.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 32),
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: Color(0xFFEFF6FF), // blue-50
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.nfc,
size: 40,
color: Color(0xFF2563EB),
),
),
const SizedBox(height: 16),
Text(
widget.hub.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Color(0xFF0F172A),
),
),
const SizedBox(height: 8),
Text(
t.client_hubs.nfc_dialog.instruction,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 14,
),
),
const SizedBox(height: 24),
UiButton.secondary(
onPressed: _simulateNFCScan,
text: t.client_hubs.nfc_dialog.scan_button,
leadingIcon: LucideIcons.nfc,
),
if (_nfcTagId != null) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF0FDF4), // green-50
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.checkCircle,
size: 20,
color: Color(0xFF16A34A),
),
const SizedBox(width: 8),
Text(
t.client_hubs.nfc_dialog.tag_identified,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Color(0xFF0F172A),
),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFDCE8E0)),
),
child: Text(
_nfcTagId!,
style: const TextStyle(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
fontSize: 12,
color: Color(0xFF334155),
),
),
),
],
),
),
],
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: UiButton.secondary(
onPressed: widget.onCancel,
text: t.common.cancel,
),
),
const SizedBox(width: 12),
Expanded(
child: UiButton.primary(
onPressed: _nfcTagId != null
? () => widget.onAssign(_nfcTagId!)
: null,
text: t.client_hubs.nfc_dialog.assign_button,
),
),
],
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,37 @@
name: client_hubs
description: "Client hubs management feature for the KROW platform."
version: 0.0.1
publish_to: none
resolution: workspace
environment:
sdk: '>=3.10.0 <4.0.0'
flutter: ">=3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.2
equatable: ^2.0.5
lucide_icons: ^0.257.0
# KROW Packages
krow_core:
path: ../../../core
krow_domain:
path: ../../../domain
krow_data_connect:
path: ../../../data_connect
design_system:
path: ../../../design_system
core_localization:
path: ../../../core_localization
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true