Move apps to mobile directory structure
Relocated all app directories (client, design_system_viewer, staff) and their contents under the new 'apps/mobile' path. This change improves project organization and prepares for future platform-specific structuring.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
library client_authentication;
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'src/data/repositories_impl/auth_repository_impl.dart';
|
||||
import 'src/domain/repositories/auth_repository_interface.dart';
|
||||
import 'src/domain/usecases/sign_in_with_email_use_case.dart';
|
||||
import 'src/domain/usecases/sign_in_with_social_use_case.dart';
|
||||
import 'src/domain/usecases/sign_out_use_case.dart';
|
||||
import 'src/domain/usecases/sign_up_with_email_use_case.dart';
|
||||
import 'src/presentation/blocs/client_auth_bloc.dart';
|
||||
import 'src/presentation/pages/client_get_started_page.dart';
|
||||
import 'src/presentation/pages/client_sign_in_page.dart';
|
||||
import 'src/presentation/pages/client_sign_up_page.dart';
|
||||
|
||||
export 'src/presentation/pages/client_get_started_page.dart';
|
||||
export 'src/presentation/pages/client_sign_in_page.dart';
|
||||
export 'src/presentation/pages/client_sign_up_page.dart';
|
||||
export 'src/presentation/navigation/client_auth_navigator.dart';
|
||||
export 'package:core_localization/core_localization.dart';
|
||||
|
||||
/// A [Module] for the client authentication feature.
|
||||
class ClientAuthenticationModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => [DataConnectModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<AuthRepositoryInterface>(
|
||||
() => AuthRepositoryImpl(
|
||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
||||
dataConnect: ExampleConnector.instance,
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(
|
||||
() => SignInWithEmailUseCase(i.get<AuthRepositoryInterface>()),
|
||||
);
|
||||
i.addLazySingleton(
|
||||
() => SignUpWithEmailUseCase(i.get<AuthRepositoryInterface>()),
|
||||
);
|
||||
i.addLazySingleton(
|
||||
() => SignInWithSocialUseCase(i.get<AuthRepositoryInterface>()),
|
||||
);
|
||||
i.addLazySingleton(() => SignOutUseCase(i.get<AuthRepositoryInterface>()));
|
||||
|
||||
// BLoCs
|
||||
i.addLazySingleton<ClientAuthBloc>(
|
||||
() => ClientAuthBloc(
|
||||
signInWithEmail: i.get<SignInWithEmailUseCase>(),
|
||||
signUpWithEmail: i.get<SignUpWithEmailUseCase>(),
|
||||
signInWithSocial: i.get<SignInWithSocialUseCase>(),
|
||||
signOut: i.get<SignOutUseCase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(r) {
|
||||
r.child('/', child: (_) => const ClientGetStartedPage());
|
||||
r.child('/client-sign-in', child: (_) => const ClientSignInPage());
|
||||
r.child('/client-sign-up', child: (_) => const ClientSignUpPage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
import '../../domain/repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Production-ready implementation of the [AuthRepositoryInterface] for the client app.
|
||||
///
|
||||
/// This implementation integrates with Firebase Authentication for user
|
||||
/// identity management and Krow's Data Connect SDK for storing user profile data.
|
||||
class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
final firebase.FirebaseAuth _firebaseAuth;
|
||||
final dc.ExampleConnector _dataConnect;
|
||||
|
||||
/// Creates an [AuthRepositoryImpl] with the real dependencies.
|
||||
AuthRepositoryImpl({
|
||||
required firebase.FirebaseAuth firebaseAuth,
|
||||
required dc.ExampleConnector dataConnect,
|
||||
}) : _firebaseAuth = firebaseAuth,
|
||||
_dataConnect = dataConnect;
|
||||
|
||||
@override
|
||||
Future<domain.User> signInWithEmail({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final credential = await _firebaseAuth.signInWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final firebaseUser = credential.user;
|
||||
if (firebaseUser == null) {
|
||||
throw Exception('Sign-in failed, no Firebase user received.');
|
||||
}
|
||||
|
||||
return _getUserProfile(
|
||||
firebaseUserId: firebaseUser.uid,
|
||||
fallbackEmail: firebaseUser.email ?? email,
|
||||
);
|
||||
|
||||
//TO-DO: validate that user is business role and has business account
|
||||
|
||||
} on firebase.FirebaseAuthException catch (e) {
|
||||
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
|
||||
throw Exception('Incorrect email or password.');
|
||||
} else {
|
||||
throw Exception('Authentication error: ${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to sign in and fetch user data: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.User> signUpWithEmail({
|
||||
required String companyName,
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final credential = await _firebaseAuth.createUserWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final firebaseUser = credential.user;
|
||||
if (firebaseUser == null) {
|
||||
throw Exception('Sign-up failed, Firebase user could not be created.');
|
||||
}
|
||||
|
||||
// Client-specific business logic:
|
||||
// 1. Create a `Business` entity.
|
||||
// 2. Create a `User` entity associated with the business.
|
||||
final createBusinessResponse = await _dataConnect.createBusiness(
|
||||
businessName: companyName,
|
||||
userId: firebaseUser.uid,
|
||||
rateGroup: dc.BusinessRateGroup.STANDARD,
|
||||
status: dc.BusinessStatus.PENDING,
|
||||
).execute();
|
||||
|
||||
final businessData = createBusinessResponse.data?.business_insert;
|
||||
if (businessData == null) {
|
||||
await firebaseUser.delete(); // Rollback if business creation fails
|
||||
throw Exception('Business creation failed after Firebase user registration.');
|
||||
}
|
||||
|
||||
final createUserResponse = await _dataConnect.createUser(
|
||||
id: firebaseUser.uid,
|
||||
role: dc.UserBaseRole.USER,
|
||||
)
|
||||
.email(email)
|
||||
.userRole('BUSINESS')
|
||||
.execute();
|
||||
|
||||
final newUserData = createUserResponse.data?.user_insert;
|
||||
if (newUserData == null) {
|
||||
await firebaseUser.delete(); // Rollback if user profile creation fails
|
||||
// TO-DO: Also delete the created Business if this fails
|
||||
throw Exception('User profile creation failed after Firebase user registration.');
|
||||
}
|
||||
|
||||
return _getUserProfile(
|
||||
firebaseUserId: firebaseUser.uid,
|
||||
fallbackEmail: firebaseUser.email ?? email,
|
||||
);
|
||||
|
||||
} on firebase.FirebaseAuthException catch (e) {
|
||||
if (e.code == 'weak-password') {
|
||||
throw Exception('The password provided is too weak.');
|
||||
} else if (e.code == 'email-already-in-use') {
|
||||
throw Exception('An account already exists for that email address.');
|
||||
} else {
|
||||
throw Exception('Sign-up error: ${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to sign up and create user data: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await _firebaseAuth.signOut();
|
||||
} catch (e) {
|
||||
throw Exception('Error signing out: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.User> signInWithSocial({required String provider}) {
|
||||
throw UnimplementedError('Social authentication with $provider is not yet implemented.');
|
||||
}
|
||||
|
||||
Future<domain.User> _getUserProfile({
|
||||
required String firebaseUserId,
|
||||
required String? fallbackEmail,
|
||||
}) async {
|
||||
final response = await _dataConnect.getUserById(id: firebaseUserId).execute();
|
||||
final user = response.data?.user;
|
||||
if (user == null) {
|
||||
throw Exception('Authenticated user profile not found in database.');
|
||||
}
|
||||
|
||||
final email = user.email ?? fallbackEmail;
|
||||
if (email == null || email.isEmpty) {
|
||||
throw Exception('User email is missing in profile data.');
|
||||
}
|
||||
|
||||
return domain.User(
|
||||
id: user.id,
|
||||
email: email,
|
||||
role: user.role.stringValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for the [SignInWithEmailUseCase].
|
||||
class SignInWithEmailArguments extends UseCaseArgument {
|
||||
/// The user's email address.
|
||||
final String email;
|
||||
|
||||
/// The user's password.
|
||||
final String password;
|
||||
|
||||
const SignInWithEmailArguments({required this.email, required this.password});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [email, password];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for the [SignInWithSocialUseCase].
|
||||
class SignInWithSocialArguments extends UseCaseArgument {
|
||||
/// The social provider name (e.g. 'google' or 'apple').
|
||||
final String provider;
|
||||
|
||||
const SignInWithSocialArguments({required this.provider});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [provider];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for the [SignUpWithEmailUseCase].
|
||||
class SignUpWithEmailArguments extends UseCaseArgument {
|
||||
/// The name of the company.
|
||||
final String companyName;
|
||||
|
||||
/// The user's email address.
|
||||
final String email;
|
||||
|
||||
/// The user's password.
|
||||
final String password;
|
||||
|
||||
const SignUpWithEmailArguments({
|
||||
required this.companyName,
|
||||
required this.email,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [companyName, email, password];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Interface for the Client Authentication Repository.
|
||||
///
|
||||
/// This abstraction defines the core authentication operations required for
|
||||
/// the client application, allowing the presentation layer to work with
|
||||
/// different data sources (e.g., Supabase, Firebase, or Mock) without
|
||||
/// depending on specific implementations.
|
||||
abstract class AuthRepositoryInterface {
|
||||
/// Signs in an existing client user using their email and password.
|
||||
///
|
||||
/// Returns a [User] object upon successful authentication.
|
||||
/// Throws an exception if authentication fails.
|
||||
Future<User> signInWithEmail({
|
||||
required String email,
|
||||
required String password,
|
||||
});
|
||||
|
||||
/// Registers a new client user with their business details.
|
||||
///
|
||||
/// Takes [companyName], [email], and [password] to create a new account.
|
||||
/// Returns the newly created [User].
|
||||
Future<User> signUpWithEmail({
|
||||
required String companyName,
|
||||
required String email,
|
||||
required String password,
|
||||
});
|
||||
|
||||
/// Authenticates using an OAuth provider.
|
||||
///
|
||||
/// [provider] can be 'google' or 'apple'.
|
||||
/// Returns a [User] upon successful social login.
|
||||
Future<User> signInWithSocial({required String provider});
|
||||
|
||||
/// Terminates the current user session and clears authentication tokens.
|
||||
Future<void> signOut();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../arguments/sign_in_with_email_arguments.dart';
|
||||
import '../repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Use case for signing in a client using email and password.
|
||||
///
|
||||
/// This use case encapsulates the logic for authenticating an existing user
|
||||
/// via email/password credentials.
|
||||
class SignInWithEmailUseCase
|
||||
implements UseCase<SignInWithEmailArguments, User> {
|
||||
final AuthRepositoryInterface _repository;
|
||||
|
||||
const SignInWithEmailUseCase(this._repository);
|
||||
|
||||
/// Executes the sign-in operation.
|
||||
@override
|
||||
Future<User> call(SignInWithEmailArguments params) {
|
||||
return _repository.signInWithEmail(
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../arguments/sign_in_with_social_arguments.dart';
|
||||
import '../repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Use case for signing in a client via social providers (Google/Apple).
|
||||
class SignInWithSocialUseCase
|
||||
implements UseCase<SignInWithSocialArguments, User> {
|
||||
final AuthRepositoryInterface _repository;
|
||||
|
||||
const SignInWithSocialUseCase(this._repository);
|
||||
|
||||
/// Executes the social sign-in operation.
|
||||
@override
|
||||
Future<User> call(SignInWithSocialArguments params) {
|
||||
return _repository.signInWithSocial(provider: params.provider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Use case for signing out the current client user.
|
||||
///
|
||||
/// This use case handles the termination of the user's session and
|
||||
/// clearing of any local authentication tokens.
|
||||
class SignOutUseCase implements NoInputUseCase<void> {
|
||||
final AuthRepositoryInterface _repository;
|
||||
|
||||
const SignOutUseCase(this._repository);
|
||||
|
||||
/// Executes the sign-out operation.
|
||||
@override
|
||||
Future<void> call() {
|
||||
return _repository.signOut();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../arguments/sign_up_with_email_arguments.dart';
|
||||
import '../repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Use case for registering a new client user.
|
||||
///
|
||||
/// This use case handles the creation of a new client account using
|
||||
/// email, password, and company details.
|
||||
class SignUpWithEmailUseCase
|
||||
implements UseCase<SignUpWithEmailArguments, User> {
|
||||
final AuthRepositoryInterface _repository;
|
||||
|
||||
const SignUpWithEmailUseCase(this._repository);
|
||||
|
||||
/// Executes the sign-up operation.
|
||||
@override
|
||||
Future<User> call(SignUpWithEmailArguments params) {
|
||||
return _repository.signUpWithEmail(
|
||||
companyName: params.companyName,
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../domain/arguments/sign_in_with_email_arguments.dart';
|
||||
import '../../domain/arguments/sign_in_with_social_arguments.dart';
|
||||
import '../../domain/arguments/sign_up_with_email_arguments.dart';
|
||||
import '../../domain/usecases/sign_in_with_email_use_case.dart';
|
||||
import '../../domain/usecases/sign_up_with_email_use_case.dart';
|
||||
import '../../domain/usecases/sign_in_with_social_use_case.dart';
|
||||
import '../../domain/usecases/sign_out_use_case.dart';
|
||||
import 'client_auth_event.dart';
|
||||
import 'client_auth_state.dart';
|
||||
|
||||
/// Business Logic Component for Client Authentication.
|
||||
///
|
||||
/// This BLoC manages the state transitions for the authentication flow in
|
||||
/// the client application. It handles user inputs (events), interacts with
|
||||
/// domain use cases, and emits corresponding [ClientAuthState]s.
|
||||
///
|
||||
/// Use this BLoC to handle:
|
||||
/// * Email/Password Sign In
|
||||
/// * Business Account Registration
|
||||
/// * Social Authentication
|
||||
/// * Session Termination
|
||||
class ClientAuthBloc extends Bloc<ClientAuthEvent, ClientAuthState> {
|
||||
final SignInWithEmailUseCase _signInWithEmail;
|
||||
final SignUpWithEmailUseCase _signUpWithEmail;
|
||||
final SignInWithSocialUseCase _signInWithSocial;
|
||||
final SignOutUseCase _signOut;
|
||||
|
||||
/// Initializes the BLoC with the required use cases and initial state.
|
||||
ClientAuthBloc({
|
||||
required SignInWithEmailUseCase signInWithEmail,
|
||||
required SignUpWithEmailUseCase signUpWithEmail,
|
||||
required SignInWithSocialUseCase signInWithSocial,
|
||||
required SignOutUseCase signOut,
|
||||
}) : _signInWithEmail = signInWithEmail,
|
||||
_signUpWithEmail = signUpWithEmail,
|
||||
_signInWithSocial = signInWithSocial,
|
||||
_signOut = signOut,
|
||||
super(const ClientAuthState()) {
|
||||
on<ClientSignInRequested>(_onSignInRequested);
|
||||
on<ClientSignUpRequested>(_onSignUpRequested);
|
||||
on<ClientSocialSignInRequested>(_onSocialSignInRequested);
|
||||
on<ClientSignOutRequested>(_onSignOutRequested);
|
||||
}
|
||||
|
||||
/// Handles the [ClientSignInRequested] event.
|
||||
Future<void> _onSignInRequested(
|
||||
ClientSignInRequested event,
|
||||
Emitter<ClientAuthState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientAuthStatus.loading));
|
||||
try {
|
||||
final user = await _signInWithEmail(
|
||||
SignInWithEmailArguments(email: event.email, password: event.password),
|
||||
);
|
||||
emit(state.copyWith(status: ClientAuthStatus.authenticated, user: user));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientAuthStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the [ClientSignUpRequested] event.
|
||||
Future<void> _onSignUpRequested(
|
||||
ClientSignUpRequested event,
|
||||
Emitter<ClientAuthState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientAuthStatus.loading));
|
||||
try {
|
||||
final user = await _signUpWithEmail(
|
||||
SignUpWithEmailArguments(
|
||||
companyName: event.companyName,
|
||||
email: event.email,
|
||||
password: event.password,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(status: ClientAuthStatus.authenticated, user: user));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientAuthStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the [ClientSocialSignInRequested] event.
|
||||
Future<void> _onSocialSignInRequested(
|
||||
ClientSocialSignInRequested event,
|
||||
Emitter<ClientAuthState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientAuthStatus.loading));
|
||||
try {
|
||||
final user = await _signInWithSocial(
|
||||
SignInWithSocialArguments(provider: event.provider),
|
||||
);
|
||||
emit(state.copyWith(status: ClientAuthStatus.authenticated, user: user));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientAuthStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the [ClientSignOutRequested] event.
|
||||
Future<void> _onSignOutRequested(
|
||||
ClientSignOutRequested event,
|
||||
Emitter<ClientAuthState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientAuthStatus.loading));
|
||||
try {
|
||||
await _signOut();
|
||||
emit(state.copyWith(status: ClientAuthStatus.signedOut, user: null));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientAuthStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Base class for all authentication events in the client feature.
|
||||
abstract class ClientAuthEvent extends Equatable {
|
||||
const ClientAuthEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Event dispatched when a user attempts to sign in with email and password.
|
||||
class ClientSignInRequested extends ClientAuthEvent {
|
||||
final String email;
|
||||
final String password;
|
||||
|
||||
const ClientSignInRequested({required this.email, required this.password});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [email, password];
|
||||
}
|
||||
|
||||
/// Event dispatched when a user attempts to create a new business account.
|
||||
class ClientSignUpRequested extends ClientAuthEvent {
|
||||
final String companyName;
|
||||
final String email;
|
||||
final String password;
|
||||
|
||||
const ClientSignUpRequested({
|
||||
required this.companyName,
|
||||
required this.email,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [companyName, email, password];
|
||||
}
|
||||
|
||||
/// Event dispatched for third-party authentication (Google/Apple).
|
||||
class ClientSocialSignInRequested extends ClientAuthEvent {
|
||||
final String provider;
|
||||
|
||||
const ClientSocialSignInRequested({required this.provider});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [provider];
|
||||
}
|
||||
|
||||
/// Event dispatched when the user requests to terminate their session.
|
||||
class ClientSignOutRequested extends ClientAuthEvent {
|
||||
const ClientSignOutRequested();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Enum representing the various states of the authentication process.
|
||||
enum ClientAuthStatus {
|
||||
/// Initial state before any action is taken.
|
||||
initial,
|
||||
|
||||
/// An authentication operation is in progress.
|
||||
loading,
|
||||
|
||||
/// The user has successfully authenticated.
|
||||
authenticated,
|
||||
|
||||
/// The user has successfully signed out.
|
||||
signedOut,
|
||||
|
||||
/// An error occurred during authentication.
|
||||
error,
|
||||
}
|
||||
|
||||
/// Represents the state of the client authentication flow.
|
||||
class ClientAuthState extends Equatable {
|
||||
/// Current status of the authentication process.
|
||||
final ClientAuthStatus status;
|
||||
|
||||
/// The authenticated user (if status is [ClientAuthStatus.authenticated]).
|
||||
final User? user;
|
||||
|
||||
/// Optional error message when status is [ClientAuthStatus.error].
|
||||
final String? errorMessage;
|
||||
|
||||
const ClientAuthState({
|
||||
this.status = ClientAuthStatus.initial,
|
||||
this.user,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// Creates a copy of this state with the given fields replaced by the new values.
|
||||
ClientAuthState copyWith({
|
||||
ClientAuthStatus? status,
|
||||
User? user,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return ClientAuthState(
|
||||
status: status ?? this.status,
|
||||
user: user ?? this.user,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, user, errorMessage];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
/// Typed navigation for the Client Authentication feature.
|
||||
///
|
||||
/// This extension on [IModularNavigator] provides named methods for
|
||||
/// navigating between authentication pages, reducing magic strings and
|
||||
/// improving maintainability.
|
||||
extension ClientAuthNavigator on IModularNavigator {
|
||||
/// Navigates to the sign in page using a push named route.
|
||||
void pushClientSignIn() {
|
||||
pushNamed('/client-sign-in');
|
||||
}
|
||||
|
||||
/// Navigates to the sign up page using a push named route.
|
||||
void pushClientSignUp() {
|
||||
pushNamed('/client-sign-up');
|
||||
}
|
||||
|
||||
/// Navigates to the main client home dashboard.
|
||||
///
|
||||
/// Uses absolute path navigation to reset the navigation stack if necessary.
|
||||
void navigateClientHome() {
|
||||
navigate('/client-home');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import '../navigation/client_auth_navigator.dart';
|
||||
|
||||
class ClientGetStartedPage extends StatelessWidget {
|
||||
const ClientGetStartedPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// Background Illustration/Visuals from prototype
|
||||
Positioned(
|
||||
top: -100,
|
||||
right: -100,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.secondary.withAlpha(50),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: UiConstants.space10),
|
||||
// Logo
|
||||
Center(
|
||||
child: Image.asset(
|
||||
UiImageAssets.logoBlue,
|
||||
height: 40,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Content Cards Area (Keeping prototype layout)
|
||||
Container(
|
||||
height: 300,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space6,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Representative cards from prototype
|
||||
Positioned(
|
||||
top: 20,
|
||||
left: 0,
|
||||
right: 20,
|
||||
child: _ShiftOrderCard(),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 40,
|
||||
right: 0,
|
||||
left: 40,
|
||||
child: _WorkerProfileCard(),
|
||||
),
|
||||
Positioned(top: 60, right: 10, child: _CalendarCard()),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Bottom Content
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space6,
|
||||
vertical: UiConstants.space10,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
t.client_authentication.get_started_page.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.displayM,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Text(
|
||||
t.client_authentication.get_started_page.subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Sign In Button
|
||||
UiButton.primary(
|
||||
text: t
|
||||
.client_authentication
|
||||
.get_started_page
|
||||
.sign_in_button,
|
||||
onPressed: () => Modular.to.pushClientSignIn(),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Create Account Button
|
||||
UiButton.secondary(
|
||||
text: t
|
||||
.client_authentication
|
||||
.get_started_page
|
||||
.create_account_button,
|
||||
onPressed: () => Modular.to.pushClientSignUp(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal Prototype Widgets Updated with Design System Primitives
|
||||
class _ShiftOrderCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space1),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.briefcase,
|
||||
size: 14,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text('Shift Order #824', style: UiTypography.footnote1b),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagPending,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
child: Text(
|
||||
'Pending',
|
||||
style: UiTypography.footnote2m.copyWith(
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
'Event Staffing - Hilton Hotel',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkerProfileCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: UiColors.primary.withOpacity(0.1),
|
||||
child: const Icon(UiIcons.user, size: 16, color: UiColors.primary),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Alex Thompson', style: UiTypography.footnote1b),
|
||||
Text(
|
||||
'Professional Waiter • 4.9★',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CalendarCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.accent,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(4, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.calendar,
|
||||
size: 20,
|
||||
color: UiColors.accentForeground,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:client_authentication/src/presentation/widgets/common/section_titles.dart';
|
||||
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 '../blocs/client_auth_bloc.dart';
|
||||
import '../blocs/client_auth_event.dart';
|
||||
import '../blocs/client_auth_state.dart';
|
||||
import '../navigation/client_auth_navigator.dart';
|
||||
import '../widgets/client_sign_in_page/client_sign_in_form.dart';
|
||||
import '../widgets/common/auth_divider.dart';
|
||||
import '../widgets/common/auth_social_button.dart';
|
||||
|
||||
/// Page for client users to sign in to their account.
|
||||
///
|
||||
/// This page provides email/password authentication as well as social sign-in
|
||||
/// options via Apple and Google. It matches the design system standards
|
||||
/// for client-facing authentication flows.
|
||||
class ClientSignInPage extends StatelessWidget {
|
||||
/// Creates a [ClientSignInPage].
|
||||
const ClientSignInPage({super.key});
|
||||
|
||||
/// Dispatches the sign in event to the BLoC.
|
||||
void _handleSignIn(
|
||||
BuildContext context, {
|
||||
required String email,
|
||||
required String password,
|
||||
}) {
|
||||
BlocProvider.of<ClientAuthBloc>(
|
||||
context,
|
||||
).add(ClientSignInRequested(email: email, password: password));
|
||||
}
|
||||
|
||||
/// Dispatches the social sign in event to the BLoC.
|
||||
void _handleSocialSignIn(BuildContext context, {required String provider}) {
|
||||
BlocProvider.of<ClientAuthBloc>(
|
||||
context,
|
||||
).add(ClientSocialSignInRequested(provider: provider));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.client_authentication.sign_in_page;
|
||||
final authBloc = Modular.get<ClientAuthBloc>();
|
||||
|
||||
return BlocProvider.value(
|
||||
value: authBloc,
|
||||
child: BlocConsumer<ClientAuthBloc, ClientAuthState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == ClientAuthStatus.authenticated) {
|
||||
Modular.to.navigateClientHome();
|
||||
} else if (state.status == ClientAuthStatus.error) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage ?? 'Authentication Error'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final isLoading = state.status == ClientAuthStatus.loading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: const UiAppBar(showBackButton: true),
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space6,
|
||||
UiConstants.space8,
|
||||
UiConstants.space6,
|
||||
0,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SectionTitle(title: i18n.title, subtitle: i18n.subtitle),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Sign In Form
|
||||
ClientSignInForm(
|
||||
isLoading: isLoading,
|
||||
onSignIn: ({required email, required password}) =>
|
||||
_handleSignIn(
|
||||
context,
|
||||
email: email,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Divider
|
||||
AuthDivider(text: i18n.or_divider),
|
||||
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Social Buttons
|
||||
AuthSocialButton(
|
||||
text: i18n.social_apple,
|
||||
icon: UiIcons.apple,
|
||||
onPressed: () =>
|
||||
_handleSocialSignIn(context, provider: 'apple'),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
AuthSocialButton(
|
||||
text: i18n.social_google,
|
||||
icon: UiIcons.google,
|
||||
onPressed: () =>
|
||||
_handleSocialSignIn(context, provider: 'google'),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Sign Up Link
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
i18n.no_account,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
GestureDetector(
|
||||
onTap: () => Modular.to.pushClientSignUp(),
|
||||
child: Text(
|
||||
i18n.sign_up_link,
|
||||
style: UiTypography.body2m.textLink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space10),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:client_authentication/src/presentation/widgets/common/section_titles.dart';
|
||||
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 '../blocs/client_auth_bloc.dart';
|
||||
import '../blocs/client_auth_event.dart';
|
||||
import '../blocs/client_auth_state.dart';
|
||||
import '../navigation/client_auth_navigator.dart';
|
||||
import '../widgets/client_sign_up_page/client_sign_up_form.dart';
|
||||
import '../widgets/common/auth_divider.dart';
|
||||
import '../widgets/common/auth_social_button.dart';
|
||||
|
||||
/// Page for client users to sign up for a new account.
|
||||
///
|
||||
/// This page collects company details, email, and password, and offers
|
||||
/// social sign-up options. It adheres to the design system standards.
|
||||
class ClientSignUpPage extends StatelessWidget {
|
||||
/// Creates a [ClientSignUpPage].
|
||||
const ClientSignUpPage({super.key});
|
||||
|
||||
/// Validates inputs and dispatches the sign up event.
|
||||
void _handleSignUp(
|
||||
BuildContext context, {
|
||||
required String companyName,
|
||||
required String email,
|
||||
required String password,
|
||||
}) {
|
||||
BlocProvider.of<ClientAuthBloc>(context).add(
|
||||
ClientSignUpRequested(
|
||||
companyName: companyName,
|
||||
email: email,
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatches the social sign up event.
|
||||
void _handleSocialSignUp(BuildContext context, {required String provider}) {
|
||||
BlocProvider.of<ClientAuthBloc>(
|
||||
context,
|
||||
).add(ClientSocialSignInRequested(provider: provider));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.client_authentication.sign_up_page;
|
||||
final authBloc = Modular.get<ClientAuthBloc>();
|
||||
|
||||
return BlocProvider.value(
|
||||
value: authBloc,
|
||||
child: BlocConsumer<ClientAuthBloc, ClientAuthState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == ClientAuthStatus.authenticated) {
|
||||
Modular.to.navigateClientHome();
|
||||
} else if (state.status == ClientAuthStatus.error) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage ?? 'Authentication Error'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final isLoading = state.status == ClientAuthStatus.loading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: const UiAppBar(showBackButton: true),
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space6,
|
||||
UiConstants.space8,
|
||||
UiConstants.space6,
|
||||
0,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SectionTitle(title: i18n.title, subtitle: i18n.subtitle),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Sign Up Form
|
||||
ClientSignUpForm(
|
||||
isLoading: isLoading,
|
||||
onSignUp:
|
||||
({
|
||||
required companyName,
|
||||
required email,
|
||||
required password,
|
||||
}) => _handleSignUp(
|
||||
context,
|
||||
companyName: companyName,
|
||||
email: email,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Divider
|
||||
// Divider
|
||||
AuthDivider(text: i18n.or_divider),
|
||||
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Social Buttons
|
||||
// Social Buttons
|
||||
AuthSocialButton(
|
||||
text: i18n.social_apple,
|
||||
icon: UiIcons.apple,
|
||||
onPressed: () =>
|
||||
_handleSocialSignUp(context, provider: 'apple'),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
AuthSocialButton(
|
||||
text: i18n.social_google,
|
||||
icon: UiIcons.google,
|
||||
onPressed: () =>
|
||||
_handleSocialSignUp(context, provider: 'google'),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Sign In Link
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
i18n.has_account,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
GestureDetector(
|
||||
onTap: () => Modular.to.pushClientSignIn(),
|
||||
child: Text(
|
||||
i18n.sign_in_link,
|
||||
style: UiTypography.body2m.textLink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space10),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A form widget for client sign-in.
|
||||
///
|
||||
/// This widget handles user input for email and password and delegates
|
||||
/// authentication events to the parent via callbacks.
|
||||
class ClientSignInForm extends StatefulWidget {
|
||||
/// Callback when the sign-in button is pressed.
|
||||
final void Function({required String email, required String password})
|
||||
onSignIn;
|
||||
|
||||
/// Whether the authentication is currently loading.
|
||||
final bool isLoading;
|
||||
|
||||
/// Creates a [ClientSignInForm].
|
||||
const ClientSignInForm({
|
||||
super.key,
|
||||
required this.onSignIn,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ClientSignInForm> createState() => _ClientSignInFormState();
|
||||
}
|
||||
|
||||
class _ClientSignInFormState extends State<ClientSignInForm> {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
widget.onSignIn(
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.client_authentication.sign_in_page;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Email Field
|
||||
UiTextField(
|
||||
label: i18n.email_label,
|
||||
hintText: i18n.email_hint,
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
|
||||
// Password Field
|
||||
UiTextField(
|
||||
label: i18n.password_label,
|
||||
hintText: i18n.password_hint,
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
suffix: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? UiIcons.eyeOff : UiIcons.eye,
|
||||
color: UiColors.iconSecondary,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
|
||||
// Forgot Password
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: Text(
|
||||
i18n.forgot_password,
|
||||
style: UiTypography.body2r.textLink,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Sign In Button
|
||||
UiButton.primary(
|
||||
text: widget.isLoading ? null : i18n.sign_in_button,
|
||||
onPressed: widget.isLoading ? null : _handleSubmit,
|
||||
child: widget.isLoading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: UiColors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A form widget for client sign-up.
|
||||
///
|
||||
/// This widget handles user input for company name, email, and password,
|
||||
/// and delegates registration events to the parent via callbacks.
|
||||
class ClientSignUpForm extends StatefulWidget {
|
||||
/// Callback when the sign-up button is pressed.
|
||||
final void Function({
|
||||
required String companyName,
|
||||
required String email,
|
||||
required String password,
|
||||
})
|
||||
onSignUp;
|
||||
|
||||
/// Whether the authentication is currently loading.
|
||||
final bool isLoading;
|
||||
|
||||
/// Creates a [ClientSignUpForm].
|
||||
const ClientSignUpForm({
|
||||
super.key,
|
||||
required this.onSignUp,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ClientSignUpForm> createState() => _ClientSignUpFormState();
|
||||
}
|
||||
|
||||
class _ClientSignUpFormState extends State<ClientSignUpForm> {
|
||||
final _companyController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_companyController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (_passwordController.text != _confirmPasswordController.text) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
|
||||
return;
|
||||
}
|
||||
|
||||
widget.onSignUp(
|
||||
companyName: _companyController.text,
|
||||
email: _emailController.text,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.client_authentication.sign_up_page;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Company Name Field
|
||||
UiTextField(
|
||||
label: i18n.company_label,
|
||||
hintText: i18n.company_hint,
|
||||
controller: _companyController,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Email Field
|
||||
UiTextField(
|
||||
label: i18n.email_label,
|
||||
hintText: i18n.email_hint,
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Password Field
|
||||
UiTextField(
|
||||
label: i18n.password_label,
|
||||
hintText: i18n.password_hint,
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.next,
|
||||
suffix: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? UiIcons.eyeOff : UiIcons.eye,
|
||||
color: UiColors.iconSecondary,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Confirm Password Field
|
||||
UiTextField(
|
||||
label: i18n.confirm_password_label,
|
||||
hintText: i18n.confirm_password_hint,
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _handleSubmit(),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Create Account Button
|
||||
UiButton.primary(
|
||||
text: widget.isLoading ? null : i18n.create_account_button,
|
||||
onPressed: widget.isLoading ? null : _handleSubmit,
|
||||
child: widget.isLoading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: UiColors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A divider widget with centered text, typically used to separate
|
||||
/// email/password auth from social auth headers.
|
||||
///
|
||||
/// Displays a horizontal line with text in the middle (e.g., "Or continue with").
|
||||
class AuthDivider extends StatelessWidget {
|
||||
/// The text to display in the center of the divider.
|
||||
final String text;
|
||||
|
||||
/// Creates an [AuthDivider].
|
||||
const AuthDivider({super.key, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||
child: Text(text, style: UiTypography.footnote1r.textSecondary),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A specialized button for social authentication integration.
|
||||
///
|
||||
/// This widget wraps [UiButton.secondary] to provide a consistent look and feel
|
||||
/// for social sign-in/sign-up buttons (e.g., Google, Apple).
|
||||
class AuthSocialButton extends StatelessWidget {
|
||||
/// The localizable text to display on the button (e.g., "Continue with Google").
|
||||
final String text;
|
||||
|
||||
/// The icon representing the social provider.
|
||||
final IconData icon;
|
||||
|
||||
/// Callback to execute when the button is tapped.
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// Creates an [AuthSocialButton].
|
||||
///
|
||||
/// The [text], [icon], and [onPressed] arguments must not be null.
|
||||
const AuthSocialButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UiButton.secondary(
|
||||
onPressed: onPressed,
|
||||
leadingIcon: icon,
|
||||
text: text,
|
||||
// Ensure the button spans the full width available
|
||||
size: UiButtonSize.large,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that displays a section title with a leading icon.
|
||||
class SectionTitle extends StatelessWidget {
|
||||
/// The title of the section.
|
||||
final String title;
|
||||
|
||||
/// The subtitle of the section.
|
||||
final String subtitle;
|
||||
|
||||
const SectionTitle({super.key, required this.title, required this.subtitle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: UiTypography.headline1m),
|
||||
Text(subtitle, style: UiTypography.body2r.textSecondary),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
name: client_authentication
|
||||
description: Client Authentication and Registration feature.
|
||||
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.0
|
||||
equatable: ^2.0.5
|
||||
firebase_core: ^4.2.1
|
||||
firebase_auth: ^6.1.2 # Updated for compatibility
|
||||
firebase_data_connect: ^0.2.2+1
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
path: ../../../../design_system
|
||||
core_localization:
|
||||
path: ../../../core_localization
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
bloc_test: ^9.1.0
|
||||
mocktail: ^1.0.0
|
||||
build_runner: ^2.4.15
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
133
apps/mobile/packages/features/client/home/REFACTOR_SUMMARY.md
Normal file
133
apps/mobile/packages/features/client/home/REFACTOR_SUMMARY.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Client Home Feature - Architecture Refactor Summary
|
||||
|
||||
## ✅ Completed Refactor
|
||||
|
||||
The `packages/features/client/home` feature has been successfully refactored to fully comply with KROW Clean Architecture principles.
|
||||
|
||||
## 📋 Changes Made
|
||||
|
||||
### 1. Domain Layer Improvements
|
||||
|
||||
**Created:**
|
||||
- `lib/src/domain/entities/home_dashboard_data.dart`
|
||||
- Proper domain entity to replace raw `Map<String, dynamic>`
|
||||
- Immutable, equatable data class
|
||||
- Clear field definitions with documentation
|
||||
|
||||
**Updated:**
|
||||
- `lib/src/domain/repositories/home_repository_interface.dart`
|
||||
- Changed from `abstract class` to `abstract interface class`
|
||||
- Return type changed from `Map<String, dynamic>` to `HomeDashboardData`
|
||||
|
||||
- `lib/src/domain/usecases/get_dashboard_data_usecase.dart`
|
||||
- Return type updated to `HomeDashboardData`
|
||||
|
||||
### 2. Data Layer Improvements
|
||||
|
||||
**Updated:**
|
||||
- `lib/src/data/repositories_impl/home_repository_impl.dart`
|
||||
- Returns `HomeDashboardData` entity instead of raw map
|
||||
- Properly typed mock data
|
||||
|
||||
### 3. Presentation Layer Refactor
|
||||
|
||||
**Major Changes to `client_home_page.dart`:**
|
||||
- ✅ Converted from `StatefulWidget` to `StatelessWidget`
|
||||
- ✅ Removed local state management (moved to BLoC)
|
||||
- ✅ BLoC lifecycle managed by `BlocProvider.create`
|
||||
- ✅ All event dispatching uses `BlocProvider.of<ClientHomeBloc>(context)`
|
||||
- ✅ Removed direct BLoC instance storage
|
||||
- ✅ Fixed deprecated `withOpacity` → `withValues(alpha:)`
|
||||
|
||||
**Updated `client_home_state.dart`:**
|
||||
- Replaced individual primitive fields with `HomeDashboardData` entity
|
||||
- Simplified state structure
|
||||
- Cleaner `copyWith` implementation
|
||||
|
||||
**Updated `client_home_bloc.dart`:**
|
||||
- Simplified event handler to use entity directly
|
||||
- No more manual field extraction from maps
|
||||
|
||||
**Widget Updates:**
|
||||
- `coverage_widget.dart`: Now accepts typed parameters
|
||||
- All widgets: Fixed deprecated `withOpacity` calls
|
||||
- `shift_order_form_sheet.dart`: Fixed deprecated `value` → `initialValue`
|
||||
|
||||
## 🎯 Architecture Compliance
|
||||
|
||||
### ✅ Clean Architecture Rules
|
||||
- [x] Domain layer is pure Dart (entities only)
|
||||
- [x] Repository interfaces in domain, implementations in data
|
||||
- [x] Use cases properly delegate to repositories
|
||||
- [x] Presentation layer depends on domain abstractions
|
||||
- [x] No feature-to-feature imports
|
||||
|
||||
### ✅ Presentation Rules
|
||||
- [x] Page is `StatelessWidget`
|
||||
- [x] State managed by BLoC
|
||||
- [x] No business logic in page
|
||||
- [x] BLoC lifecycle properly managed
|
||||
- [x] Named parameters used throughout
|
||||
|
||||
### ✅ Code Quality
|
||||
- [x] No deprecation warnings
|
||||
- [x] All files have doc comments
|
||||
- [x] Consistent naming conventions
|
||||
- [x] `flutter analyze` passes with 0 issues
|
||||
|
||||
## 📊 Before vs After
|
||||
|
||||
### Before:
|
||||
```dart
|
||||
// StatefulWidget with local state
|
||||
class ClientHomePage extends StatefulWidget {
|
||||
late final ClientHomeBloc _homeBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_homeBloc = Modular.get<ClientHomeBloc>();
|
||||
}
|
||||
}
|
||||
|
||||
// Raw maps in domain
|
||||
Future<Map<String, dynamic>> getDashboardData();
|
||||
|
||||
// Manual field extraction
|
||||
weeklySpending: data['weeklySpending'] as double?,
|
||||
```
|
||||
|
||||
### After:
|
||||
```dart
|
||||
// StatelessWidget, BLoC-managed
|
||||
class ClientHomePage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ClientHomeBloc>(
|
||||
create: (context) => Modular.get<ClientHomeBloc>()..add(ClientHomeStarted()),
|
||||
// ...
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Typed entities
|
||||
Future<HomeDashboardData> getDashboardData();
|
||||
|
||||
// Direct entity usage
|
||||
dashboardData: data,
|
||||
```
|
||||
|
||||
## 🔍 Reference Alignment
|
||||
|
||||
The refactored code now matches the structure of `packages/features/staff/authentication`:
|
||||
- StatelessWidget pages
|
||||
- BLoC-managed state
|
||||
- Typed domain entities
|
||||
- Clean separation of concerns
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
The feature is now production-ready and follows all architectural guidelines. Future enhancements should:
|
||||
1. Add unit tests for use cases
|
||||
2. Add widget tests for pages
|
||||
3. Add integration tests for complete flows
|
||||
4. Consider extracting reusable widgets to design_system if used across features
|
||||
@@ -0,0 +1,44 @@
|
||||
library client_home;
|
||||
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'src/data/repositories_impl/home_repository_impl.dart';
|
||||
import 'src/domain/repositories/home_repository_interface.dart';
|
||||
import 'src/domain/usecases/get_dashboard_data_usecase.dart';
|
||||
import 'src/presentation/blocs/client_home_bloc.dart';
|
||||
import 'src/presentation/pages/client_home_page.dart';
|
||||
|
||||
export 'src/presentation/pages/client_home_page.dart';
|
||||
export 'src/presentation/navigation/client_home_navigator.dart';
|
||||
|
||||
/// A [Module] for the client home feature.
|
||||
///
|
||||
/// This module configures the dependencies for the client home feature,
|
||||
/// including repositories, use cases, and BLoCs.
|
||||
class ClientHomeModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => [DataConnectModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<HomeRepositoryInterface>(
|
||||
() => HomeRepositoryImpl(i.get<HomeRepositoryMock>()),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(GetDashboardDataUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.add<ClientHomeBloc>(
|
||||
() => ClientHomeBloc(
|
||||
getDashboardDataUseCase: i.get<GetDashboardDataUseCase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(r) {
|
||||
r.child('/', child: (_) => const ClientHomePage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/home_repository_interface.dart';
|
||||
|
||||
/// Implementation of [HomeRepositoryInterface] that delegates to [HomeRepositoryMock].
|
||||
///
|
||||
/// This implementation resides in the data layer and acts as a bridge between the
|
||||
/// domain layer and the data source (in this case, a mock from data_connect).
|
||||
class HomeRepositoryImpl implements HomeRepositoryInterface {
|
||||
final HomeRepositoryMock _mock;
|
||||
|
||||
/// Creates a [HomeRepositoryImpl].
|
||||
///
|
||||
/// Requires a [HomeRepositoryMock] to perform data operations.
|
||||
HomeRepositoryImpl(this._mock);
|
||||
|
||||
@override
|
||||
Future<HomeDashboardData> getDashboardData() {
|
||||
return _mock.getDashboardData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Interface for the Client Home repository.
|
||||
///
|
||||
/// This repository is responsible for providing data required for the
|
||||
/// client home screen dashboard.
|
||||
abstract interface class HomeRepositoryInterface {
|
||||
/// Fetches the [HomeDashboardData] containing aggregated dashboard metrics.
|
||||
Future<HomeDashboardData> getDashboardData();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/home_repository_interface.dart';
|
||||
|
||||
/// Use case to fetch dashboard data for the client home screen.
|
||||
///
|
||||
/// This use case coordinates with the [HomeRepositoryInterface] to retrieve
|
||||
/// the [HomeDashboardData] required for the dashboard display.
|
||||
class GetDashboardDataUseCase implements NoInputUseCase<HomeDashboardData> {
|
||||
final HomeRepositoryInterface _repository;
|
||||
|
||||
/// Creates a [GetDashboardDataUseCase].
|
||||
GetDashboardDataUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<HomeDashboardData> call() {
|
||||
return _repository.getDashboardData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../domain/usecases/get_dashboard_data_usecase.dart';
|
||||
import 'client_home_event.dart';
|
||||
import 'client_home_state.dart';
|
||||
|
||||
/// BLoC responsible for managing the state and business logic of the client home dashboard.
|
||||
class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState> {
|
||||
final GetDashboardDataUseCase _getDashboardDataUseCase;
|
||||
|
||||
ClientHomeBloc({required GetDashboardDataUseCase getDashboardDataUseCase})
|
||||
: _getDashboardDataUseCase = getDashboardDataUseCase,
|
||||
super(const ClientHomeState()) {
|
||||
on<ClientHomeStarted>(_onStarted);
|
||||
on<ClientHomeEditModeToggled>(_onEditModeToggled);
|
||||
on<ClientHomeWidgetVisibilityToggled>(_onWidgetVisibilityToggled);
|
||||
on<ClientHomeWidgetReordered>(_onWidgetReordered);
|
||||
on<ClientHomeLayoutReset>(_onLayoutReset);
|
||||
}
|
||||
|
||||
Future<void> _onStarted(
|
||||
ClientHomeStarted event,
|
||||
Emitter<ClientHomeState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ClientHomeStatus.loading));
|
||||
try {
|
||||
final data = await _getDashboardDataUseCase();
|
||||
emit(
|
||||
state.copyWith(status: ClientHomeStatus.success, dashboardData: data),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ClientHomeStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onEditModeToggled(
|
||||
ClientHomeEditModeToggled event,
|
||||
Emitter<ClientHomeState> emit,
|
||||
) {
|
||||
emit(state.copyWith(isEditMode: !state.isEditMode));
|
||||
}
|
||||
|
||||
void _onWidgetVisibilityToggled(
|
||||
ClientHomeWidgetVisibilityToggled event,
|
||||
Emitter<ClientHomeState> emit,
|
||||
) {
|
||||
final newVisibility = Map<String, bool>.from(state.widgetVisibility);
|
||||
newVisibility[event.widgetId] = !(newVisibility[event.widgetId] ?? true);
|
||||
emit(state.copyWith(widgetVisibility: newVisibility));
|
||||
}
|
||||
|
||||
void _onWidgetReordered(
|
||||
ClientHomeWidgetReordered event,
|
||||
Emitter<ClientHomeState> emit,
|
||||
) {
|
||||
final newList = List<String>.from(state.widgetOrder);
|
||||
int oldIndex = event.oldIndex;
|
||||
int newIndex = event.newIndex;
|
||||
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = newList.removeAt(oldIndex);
|
||||
newList.insert(newIndex, item);
|
||||
|
||||
emit(state.copyWith(widgetOrder: newList));
|
||||
}
|
||||
|
||||
void _onLayoutReset(
|
||||
ClientHomeLayoutReset event,
|
||||
Emitter<ClientHomeState> emit,
|
||||
) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
widgetOrder: const [
|
||||
'actions',
|
||||
'reorder',
|
||||
'coverage',
|
||||
'spending',
|
||||
'liveActivity',
|
||||
],
|
||||
widgetVisibility: const {
|
||||
'actions': true,
|
||||
'reorder': true,
|
||||
'coverage': true,
|
||||
'spending': true,
|
||||
'liveActivity': true,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class ClientHomeEvent extends Equatable {
|
||||
const ClientHomeEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class ClientHomeStarted extends ClientHomeEvent {}
|
||||
|
||||
class ClientHomeEditModeToggled extends ClientHomeEvent {}
|
||||
|
||||
class ClientHomeWidgetVisibilityToggled extends ClientHomeEvent {
|
||||
final String widgetId;
|
||||
const ClientHomeWidgetVisibilityToggled(this.widgetId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [widgetId];
|
||||
}
|
||||
|
||||
class ClientHomeWidgetReordered extends ClientHomeEvent {
|
||||
final int oldIndex;
|
||||
final int newIndex;
|
||||
const ClientHomeWidgetReordered(this.oldIndex, this.newIndex);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [oldIndex, newIndex];
|
||||
}
|
||||
|
||||
class ClientHomeLayoutReset extends ClientHomeEvent {}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Status of the client home dashboard.
|
||||
enum ClientHomeStatus { initial, loading, success, error }
|
||||
|
||||
/// Represents the state of the client home dashboard.
|
||||
class ClientHomeState extends Equatable {
|
||||
final ClientHomeStatus status;
|
||||
final List<String> widgetOrder;
|
||||
final Map<String, bool> widgetVisibility;
|
||||
final bool isEditMode;
|
||||
final String? errorMessage;
|
||||
final HomeDashboardData dashboardData;
|
||||
|
||||
const ClientHomeState({
|
||||
this.status = ClientHomeStatus.initial,
|
||||
this.widgetOrder = const [
|
||||
'actions',
|
||||
'reorder',
|
||||
'coverage',
|
||||
'spending',
|
||||
'liveActivity',
|
||||
],
|
||||
this.widgetVisibility = const {
|
||||
'actions': true,
|
||||
'reorder': true,
|
||||
'coverage': true,
|
||||
'spending': true,
|
||||
'liveActivity': true,
|
||||
},
|
||||
this.isEditMode = false,
|
||||
this.errorMessage,
|
||||
this.dashboardData = const HomeDashboardData(
|
||||
weeklySpending: 4250.0,
|
||||
next7DaysSpending: 6100.0,
|
||||
weeklyShifts: 12,
|
||||
next7DaysScheduled: 18,
|
||||
totalNeeded: 10,
|
||||
totalFilled: 8,
|
||||
),
|
||||
});
|
||||
|
||||
ClientHomeState copyWith({
|
||||
ClientHomeStatus? status,
|
||||
List<String>? widgetOrder,
|
||||
Map<String, bool>? widgetVisibility,
|
||||
bool? isEditMode,
|
||||
String? errorMessage,
|
||||
HomeDashboardData? dashboardData,
|
||||
}) {
|
||||
return ClientHomeState(
|
||||
status: status ?? this.status,
|
||||
widgetOrder: widgetOrder ?? this.widgetOrder,
|
||||
widgetVisibility: widgetVisibility ?? this.widgetVisibility,
|
||||
isEditMode: isEditMode ?? this.isEditMode,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
dashboardData: dashboardData ?? this.dashboardData,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
widgetOrder,
|
||||
widgetVisibility,
|
||||
isEditMode,
|
||||
errorMessage,
|
||||
dashboardData,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
/// Extension on [IModularNavigator] to provide strongly-typed navigation
|
||||
/// for the client home feature.
|
||||
extension ClientHomeNavigator on IModularNavigator {
|
||||
/// Navigates to the client home page.
|
||||
void pushClientHome() {
|
||||
pushNamed('/client/home/');
|
||||
}
|
||||
|
||||
/// Navigates to the settings page.
|
||||
void pushSettings() {
|
||||
pushNamed('/client-settings/');
|
||||
}
|
||||
|
||||
/// Navigates to the hubs page.
|
||||
void pushHubs() {
|
||||
pushNamed('/client/hubs');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
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 '../blocs/client_home_bloc.dart';
|
||||
import '../blocs/client_home_event.dart';
|
||||
import '../blocs/client_home_state.dart';
|
||||
import '../navigation/client_home_navigator.dart';
|
||||
import '../widgets/actions_widget.dart';
|
||||
import '../widgets/coverage_widget.dart';
|
||||
import '../widgets/live_activity_widget.dart';
|
||||
import '../widgets/reorder_widget.dart';
|
||||
import '../widgets/shift_order_form_sheet.dart';
|
||||
import '../widgets/spending_widget.dart';
|
||||
|
||||
/// The main Home page for client users.
|
||||
class ClientHomePage extends StatelessWidget {
|
||||
/// Creates a [ClientHomePage].
|
||||
const ClientHomePage({super.key});
|
||||
|
||||
void _openOrderFormSheet(
|
||||
BuildContext context,
|
||||
Map<String, dynamic>? shiftData,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) {
|
||||
return ShiftOrderFormSheet(
|
||||
initialData: shiftData,
|
||||
onSubmit: (data) {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.client_home;
|
||||
|
||||
return BlocProvider<ClientHomeBloc>(
|
||||
create: (context) =>
|
||||
Modular.get<ClientHomeBloc>()..add(ClientHomeStarted()),
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(context, i18n),
|
||||
_buildEditModeBanner(i18n),
|
||||
Flexible(
|
||||
child: BlocBuilder<ClientHomeBloc, ClientHomeState>(
|
||||
builder: (context, state) {
|
||||
if (state.isEditMode) {
|
||||
return ReorderableListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space4,
|
||||
0,
|
||||
UiConstants.space4,
|
||||
100,
|
||||
),
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
BlocProvider.of<ClientHomeBloc>(
|
||||
context,
|
||||
).add(ClientHomeWidgetReordered(oldIndex, newIndex));
|
||||
},
|
||||
children: state.widgetOrder.map((id) {
|
||||
return Container(
|
||||
key: ValueKey(id),
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: UiConstants.space4,
|
||||
),
|
||||
child: _buildDraggableWidgetWrapper(
|
||||
context,
|
||||
id,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space4,
|
||||
0,
|
||||
UiConstants.space4,
|
||||
100,
|
||||
),
|
||||
children: state.widgetOrder.map((id) {
|
||||
if (!(state.widgetVisibility[id] ?? true)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space4,
|
||||
),
|
||||
child: _buildWidgetContent(context, id, state),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, dynamic i18n) {
|
||||
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
|
||||
builder: (context, state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space4,
|
||||
UiConstants.space4,
|
||||
UiConstants.space4,
|
||||
UiConstants.space3,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: UiColors.primary.withValues(alpha: 0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: UiColors.primary.withValues(alpha: 0.1),
|
||||
child: Text(
|
||||
'C',
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
i18n.dashboard.welcome_back,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
Text('Your Company', style: UiTypography.body1b),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_HeaderIconButton(
|
||||
icon: UiIcons.edit,
|
||||
isActive: state.isEditMode,
|
||||
onTap: () => BlocProvider.of<ClientHomeBloc>(
|
||||
context,
|
||||
).add(ClientHomeEditModeToggled()),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_HeaderIconButton(
|
||||
icon: UiIcons.bell,
|
||||
badgeText: '3',
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_HeaderIconButton(
|
||||
icon: UiIcons.settings,
|
||||
onTap: () => Modular.to.pushSettings(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditModeBanner(dynamic i18n) {
|
||||
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
|
||||
buildWhen: (prev, curr) => prev.isEditMode != curr.isEditMode,
|
||||
builder: (context, state) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: state.isEditMode ? 76 : 0,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space2,
|
||||
),
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(alpha: 0.1),
|
||||
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Icon(UiIcons.edit, size: 16, color: UiColors.primary),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
i18n.dashboard.edit_mode_active,
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
i18n.dashboard.drag_instruction,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
UiButton.secondary(
|
||||
text: i18n.dashboard.reset,
|
||||
onPressed: () => BlocProvider.of<ClientHomeBloc>(
|
||||
context,
|
||||
).add(ClientHomeLayoutReset()),
|
||||
size: UiButtonSize.small,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
maximumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDraggableWidgetWrapper(
|
||||
BuildContext context,
|
||||
String id,
|
||||
ClientHomeState state,
|
||||
) {
|
||||
final isVisible = state.widgetVisibility[id] ?? true;
|
||||
final title = _getWidgetTitle(id);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.gripVertical,
|
||||
size: 14,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(title, style: UiTypography.footnote1m),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
GestureDetector(
|
||||
onTap: () => BlocProvider.of<ClientHomeBloc>(
|
||||
context,
|
||||
).add(ClientHomeWidgetVisibilityToggled(id)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space1),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Icon(
|
||||
isVisible ? UiIcons.success : UiIcons.error,
|
||||
size: 14,
|
||||
color: isVisible ? UiColors.primary : UiColors.iconSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Opacity(
|
||||
opacity: isVisible ? 1.0 : 0.4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !isVisible,
|
||||
child: _buildWidgetContent(context, id, state),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWidgetContent(
|
||||
BuildContext context,
|
||||
String id,
|
||||
ClientHomeState state,
|
||||
) {
|
||||
switch (id) {
|
||||
case 'actions':
|
||||
return ActionsWidget(
|
||||
onRapidPressed: () {},
|
||||
onCreateOrderPressed: () => _openOrderFormSheet(context, null),
|
||||
onHubsPressed: () => Modular.to.pushHubs(),
|
||||
);
|
||||
case 'reorder':
|
||||
return ReorderWidget(
|
||||
onReorderPressed: (data) => _openOrderFormSheet(context, data),
|
||||
);
|
||||
case 'spending':
|
||||
return SpendingWidget(
|
||||
weeklySpending: state.dashboardData.weeklySpending,
|
||||
next7DaysSpending: state.dashboardData.next7DaysSpending,
|
||||
weeklyShifts: state.dashboardData.weeklyShifts,
|
||||
next7DaysScheduled: state.dashboardData.next7DaysScheduled,
|
||||
);
|
||||
case 'coverage':
|
||||
return CoverageWidget(
|
||||
totalNeeded: state.dashboardData.totalNeeded,
|
||||
totalConfirmed: state.dashboardData.totalFilled,
|
||||
coveragePercent: state.dashboardData.totalNeeded > 0
|
||||
? ((state.dashboardData.totalFilled /
|
||||
state.dashboardData.totalNeeded) *
|
||||
100)
|
||||
.toInt()
|
||||
: 0,
|
||||
);
|
||||
case 'liveActivity':
|
||||
return LiveActivityWidget(onViewAllPressed: () {});
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
String _getWidgetTitle(String id) {
|
||||
final i18n = t.client_home.widgets;
|
||||
switch (id) {
|
||||
case 'actions':
|
||||
return i18n.actions;
|
||||
case 'reorder':
|
||||
return i18n.reorder;
|
||||
case 'coverage':
|
||||
return i18n.coverage;
|
||||
case 'spending':
|
||||
return i18n.spending;
|
||||
case 'liveActivity':
|
||||
return i18n.live_activity;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String? badgeText;
|
||||
final bool isActive;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _HeaderIconButton({
|
||||
required this.icon,
|
||||
this.badgeText,
|
||||
this.isActive = false,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? UiColors.primary : UiColors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isActive ? UiColors.white : UiColors.iconSecondary,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
if (badgeText != null)
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.iconError,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
badgeText!,
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.white,
|
||||
fontSize: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that displays quick actions for the client.
|
||||
class ActionsWidget extends StatelessWidget {
|
||||
/// Callback when RAPID is pressed.
|
||||
final VoidCallback onRapidPressed;
|
||||
|
||||
/// Callback when Create Order is pressed.
|
||||
final VoidCallback onCreateOrderPressed;
|
||||
|
||||
/// Callback when Hubs is pressed.
|
||||
final VoidCallback onHubsPressed;
|
||||
|
||||
/// Creates an [ActionsWidget].
|
||||
const ActionsWidget({
|
||||
super.key,
|
||||
required this.onRapidPressed,
|
||||
required this.onCreateOrderPressed,
|
||||
required this.onHubsPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if client_home exists in t
|
||||
final i18n = t.client_home.actions;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ActionCard(
|
||||
title: i18n.rapid,
|
||||
subtitle: i18n.rapid_subtitle,
|
||||
icon: UiIcons.zap,
|
||||
color: const Color(0xFFFEF2F2),
|
||||
borderColor: const Color(0xFFFECACA),
|
||||
iconBgColor: const Color(0xFFFEE2E2),
|
||||
iconColor: const Color(0xFFDC2626),
|
||||
textColor: const Color(0xFF7F1D1D),
|
||||
subtitleColor: const Color(0xFFB91C1C),
|
||||
onTap: onRapidPressed,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: _ActionCard(
|
||||
title: i18n.create_order,
|
||||
subtitle: i18n.create_order_subtitle,
|
||||
icon: UiIcons.add,
|
||||
color: UiColors.white,
|
||||
borderColor: UiColors.border,
|
||||
iconBgColor: const Color(0xFFEFF6FF),
|
||||
iconColor: const Color(0xFF2563EB),
|
||||
textColor: UiColors.textPrimary,
|
||||
subtitleColor: UiColors.textSecondary,
|
||||
onTap: onCreateOrderPressed,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: _ActionCard(
|
||||
title: i18n.hubs,
|
||||
subtitle: i18n.hubs_subtitle,
|
||||
icon: UiIcons.nfc,
|
||||
color: const Color(0xFFF0FDF4),
|
||||
borderColor: const Color(0xFFBBF7D0),
|
||||
iconBgColor: const Color(0xFFDCFCE7),
|
||||
iconColor: const Color(0xFF16A34A),
|
||||
textColor: const Color(0xFF064E3B),
|
||||
subtitleColor: const Color(0xFF15803D),
|
||||
onTap: onHubsPressed,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Color borderColor;
|
||||
final Color iconBgColor;
|
||||
final Color iconColor;
|
||||
final Color textColor;
|
||||
final Color subtitleColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionCard({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.borderColor,
|
||||
required this.iconBgColor,
|
||||
required this.iconColor,
|
||||
required this.textColor,
|
||||
required this.subtitleColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 100,
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: borderColor),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.02),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBgColor,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 16),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.footnote1b.copyWith(color: textColor),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: UiTypography.footnote2r.copyWith(color: subtitleColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A dashboard widget that displays today's coverage status.
|
||||
class CoverageDashboard extends StatelessWidget {
|
||||
/// The list of shifts for today.
|
||||
final List<dynamic> shifts;
|
||||
|
||||
/// The list of applications for today's shifts.
|
||||
final List<dynamic> applications;
|
||||
|
||||
/// Creates a [CoverageDashboard].
|
||||
const CoverageDashboard({
|
||||
super.key,
|
||||
required this.shifts,
|
||||
required this.applications,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
int totalNeeded = 0;
|
||||
int totalConfirmed = 0;
|
||||
double todayCost = 0;
|
||||
|
||||
for (final s in shifts) {
|
||||
final needed = s['workersNeeded'] as int? ?? 0;
|
||||
final confirmed = s['filled'] as int? ?? 0;
|
||||
final rate = s['hourlyRate'] as double? ?? 20.0;
|
||||
|
||||
totalNeeded += needed;
|
||||
totalConfirmed += confirmed;
|
||||
todayCost += rate * 8 * confirmed;
|
||||
}
|
||||
|
||||
final coveragePercent = totalNeeded > 0
|
||||
? ((totalConfirmed / totalNeeded) * 100).round()
|
||||
: 100;
|
||||
final unfilledPositions = totalNeeded - totalConfirmed;
|
||||
|
||||
final checkedInCount = applications
|
||||
.where((a) => (a as Map)['checkInTime'] != null)
|
||||
.length;
|
||||
final lateWorkersCount = applications
|
||||
.where((a) => (a as Map)['status'] == 'LATE')
|
||||
.length;
|
||||
|
||||
final isCoverageGood = coveragePercent >= 90;
|
||||
final coverageBadgeColor = isCoverageGood
|
||||
? const Color(0xFFD1FAE5) // TODO: Use design system color if available
|
||||
: const Color(0xFFFEF3C7);
|
||||
final coverageTextColor = isCoverageGood
|
||||
? const Color(0xFF047857)
|
||||
: const Color(0xFFB45309);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.02),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("Today's Status", style: UiTypography.body1m.textSecondary),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: 2.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: coverageBadgeColor,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Text(
|
||||
'$coveragePercent% Covered',
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: coverageTextColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
_StatusCard(
|
||||
label: 'Unfilled Today',
|
||||
value: '$unfilledPositions',
|
||||
icon: UiIcons.warning,
|
||||
isWarning: unfilledPositions > 0,
|
||||
),
|
||||
if (lateWorkersCount > 0) ...[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_StatusCard(
|
||||
label: 'Running Late',
|
||||
value: '$lateWorkersCount',
|
||||
icon: UiIcons.error,
|
||||
isError: true,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
_StatusCard(
|
||||
label: 'Checked In',
|
||||
value: '$checkedInCount/$totalConfirmed',
|
||||
icon: UiIcons.success,
|
||||
isInfo: true,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_StatusCard(
|
||||
label: "Today's Cost",
|
||||
value: '\$${todayCost.round()}',
|
||||
icon: UiIcons.dollar,
|
||||
isInfo: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final bool isWarning;
|
||||
final bool isError;
|
||||
final bool isInfo;
|
||||
|
||||
const _StatusCard({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
this.isWarning = false,
|
||||
this.isError = false,
|
||||
this.isInfo = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color bg = const Color(0xFFF1F5F9);
|
||||
Color border = const Color(0xFFE2E8F0);
|
||||
Color iconColor = UiColors.iconSecondary;
|
||||
Color textColor = UiColors.textPrimary;
|
||||
|
||||
if (isWarning) {
|
||||
bg = const Color(0xFFFFFBEB);
|
||||
border = const Color(0xFFFDE68A);
|
||||
iconColor = const Color(0xFFD97706);
|
||||
textColor = const Color(0xFFB45309);
|
||||
} else if (isError) {
|
||||
bg = const Color(0xFFFEF2F2);
|
||||
border = const Color(0xFFFECACA);
|
||||
iconColor = const Color(0xFFDC2626);
|
||||
textColor = const Color(0xFFB91C1C);
|
||||
} else if (isInfo) {
|
||||
bg = const Color(0xFFEFF6FF);
|
||||
border = const Color(0xFFBFDBFE);
|
||||
iconColor = const Color(0xFF2563EB);
|
||||
textColor = const Color(0xFF1D4ED8);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border.all(color: border),
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: iconColor),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.footnote1m.copyWith(
|
||||
color: textColor.withValues(alpha: 0.8),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
value,
|
||||
style: UiTypography.headline3m.copyWith(color: textColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that displays the daily coverage metrics.
|
||||
class CoverageWidget extends StatelessWidget {
|
||||
/// The total number of shifts needed.
|
||||
final int totalNeeded;
|
||||
|
||||
/// The number of confirmed shifts.
|
||||
final int totalConfirmed;
|
||||
|
||||
/// The percentage of coverage (0-100).
|
||||
final int coveragePercent;
|
||||
|
||||
/// Creates a [CoverageWidget].
|
||||
const CoverageWidget({
|
||||
super.key,
|
||||
this.totalNeeded = 10,
|
||||
this.totalConfirmed = 8,
|
||||
this.coveragePercent = 80,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"TODAY'S COVERAGE",
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical:
|
||||
2, // 2px is not in metrics, using hardcoded for small tweaks or space0/space1
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagActive,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Text(
|
||||
'$coveragePercent% Covered',
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.textSuccess,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
icon: UiIcons.target,
|
||||
iconColor: UiColors.primary,
|
||||
label: 'Needed',
|
||||
value: '$totalNeeded',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
icon: UiIcons.success,
|
||||
iconColor: UiColors.iconSuccess,
|
||||
label: 'Filled',
|
||||
value: '$totalConfirmed',
|
||||
valueColor: UiColors.textSuccess,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
icon: UiIcons.error,
|
||||
iconColor: UiColors.iconError,
|
||||
label: 'Open',
|
||||
value: '${totalNeeded - totalConfirmed}',
|
||||
valueColor: UiColors.textError,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
const _MetricCard({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.valueColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2 + 2), // 10px
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.cardViewBackground,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.02),
|
||||
blurRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: iconColor),
|
||||
const SizedBox(width: 6), // 6px
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.footnote2m.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 6), // 6px
|
||||
Text(
|
||||
value,
|
||||
style: UiTypography.headline3m.copyWith(
|
||||
color: valueColor ?? UiColors.textPrimary,
|
||||
fontWeight:
|
||||
FontWeight.bold, // header3 is usually bold, but ensuring
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'coverage_dashboard.dart';
|
||||
|
||||
/// A widget that displays live activity information.
|
||||
class LiveActivityWidget extends StatelessWidget {
|
||||
/// Callback when "View all" is pressed.
|
||||
final VoidCallback onViewAllPressed;
|
||||
|
||||
/// Creates a [LiveActivityWidget].
|
||||
const LiveActivityWidget({super.key, required this.onViewAllPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.client_home;
|
||||
|
||||
// Mock data
|
||||
final shifts = [
|
||||
{
|
||||
'workersNeeded': 5,
|
||||
'filled': 4,
|
||||
'hourlyRate': 20.0,
|
||||
'status': 'OPEN',
|
||||
'date': DateTime.now().toIso8601String().split('T')[0],
|
||||
},
|
||||
{
|
||||
'workersNeeded': 5,
|
||||
'filled': 5,
|
||||
'hourlyRate': 22.0,
|
||||
'status': 'FILLED',
|
||||
'date': DateTime.now().toIso8601String().split('T')[0],
|
||||
},
|
||||
];
|
||||
final applications = [
|
||||
{'status': 'CONFIRMED', 'checkInTime': '09:00'},
|
||||
{'status': 'CONFIRMED', 'checkInTime': '09:05'},
|
||||
{'status': 'CONFIRMED'},
|
||||
{'status': 'LATE'},
|
||||
];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
i18n.widgets.live_activity.toUpperCase(),
|
||||
style: UiTypography.footnote1b.textSecondary.copyWith(
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: onViewAllPressed,
|
||||
child: Text(
|
||||
i18n.dashboard.view_all,
|
||||
style: UiTypography.footnote1m.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
CoverageDashboard(shifts: shifts, applications: applications),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that allows clients to reorder recent shifts.
|
||||
class ReorderWidget extends StatelessWidget {
|
||||
/// Callback when a reorder button is pressed.
|
||||
final Function(Map<String, dynamic> shiftData) onReorderPressed;
|
||||
|
||||
/// Creates a [ReorderWidget].
|
||||
const ReorderWidget({super.key, required this.onReorderPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.client_home.reorder;
|
||||
|
||||
// Mock recent orders
|
||||
final recentOrders = [
|
||||
{
|
||||
'title': 'Server',
|
||||
'location': 'Downtown Restaurant',
|
||||
'hourlyRate': 18.0,
|
||||
'hours': 6,
|
||||
'workers': 3,
|
||||
'type': 'One Day',
|
||||
},
|
||||
{
|
||||
'title': 'Bartender',
|
||||
'location': 'Rooftop Bar',
|
||||
'hourlyRate': 22.0,
|
||||
'hours': 7,
|
||||
'workers': 2,
|
||||
'type': 'One Day',
|
||||
},
|
||||
{
|
||||
'title': 'Event Staff',
|
||||
'location': 'Convention Center',
|
||||
'hourlyRate': 20.0,
|
||||
'hours': 10,
|
||||
'workers': 5,
|
||||
'type': 'Multi-Day',
|
||||
},
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
i18n.title,
|
||||
style: UiTypography.footnote1b.textSecondary.copyWith(
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
SizedBox(
|
||||
height: 140,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: recentOrders.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
itemBuilder: (context, index) {
|
||||
final order = recentOrders[index];
|
||||
final totalCost =
|
||||
(order['hourlyRate'] as double) *
|
||||
(order['hours'] as int) *
|
||||
(order['workers'] as int);
|
||||
|
||||
return Container(
|
||||
width: 260,
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.02),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.building,
|
||||
size: 16,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
order['title'] as String,
|
||||
style: UiTypography.body2b,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
order['location'] as String,
|
||||
style:
|
||||
UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'\$${totalCost.toStringAsFixed(0)}',
|
||||
style: UiTypography.body1b,
|
||||
),
|
||||
Text(
|
||||
i18n.per_hr(
|
||||
amount: order['hourlyRate'].toString(),
|
||||
) +
|
||||
' · ${order['hours']}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: [
|
||||
_Badge(
|
||||
icon: UiIcons.success,
|
||||
text: order['type'] as String,
|
||||
color: const Color(0xFF2563EB),
|
||||
bg: const Color(0xFF2563EB),
|
||||
textColor: UiColors.white,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_Badge(
|
||||
icon: UiIcons.building,
|
||||
text: '${order['workers']}',
|
||||
color: const Color(0xFF334155),
|
||||
bg: const Color(0xFFF1F5F9),
|
||||
textColor: const Color(0xFF334155),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
height: 28,
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => onReorderPressed(order),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
foregroundColor: UiColors.white,
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
icon: const Icon(UiIcons.zap, size: 12),
|
||||
label: Text(
|
||||
i18n.reorder_button,
|
||||
style: UiTypography.footnote1m,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String text;
|
||||
final Color color;
|
||||
final Color bg;
|
||||
final Color textColor;
|
||||
|
||||
const _Badge({
|
||||
required this.icon,
|
||||
required this.text,
|
||||
required this.color,
|
||||
required this.bg,
|
||||
required this.textColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: UiConstants.space1,
|
||||
),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: UiConstants.radiusSm),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 10, color: bg == textColor ? UiColors.white : color),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(text, style: UiTypography.footnote2b.copyWith(color: textColor)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A bottom sheet form for creating or reordering shifts.
|
||||
class ShiftOrderFormSheet extends StatefulWidget {
|
||||
/// Initial data for the form (e.g. from a reorder action).
|
||||
final Map<String, dynamic>? initialData;
|
||||
|
||||
/// Callback when the form is submitted.
|
||||
final Function(Map<String, dynamic> data) onSubmit;
|
||||
|
||||
/// Whether the submission is loading.
|
||||
final bool isLoading;
|
||||
|
||||
/// Creates a [ShiftOrderFormSheet].
|
||||
const ShiftOrderFormSheet({
|
||||
super.key,
|
||||
this.initialData,
|
||||
required this.onSubmit,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShiftOrderFormSheet> createState() => _ShiftOrderFormSheetState();
|
||||
}
|
||||
|
||||
class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
|
||||
late Map<String, dynamic> _formData;
|
||||
final List<String> _roles = [
|
||||
'Server',
|
||||
'Bartender',
|
||||
'Busser',
|
||||
'Cook',
|
||||
'Dishwasher',
|
||||
'Event Staff',
|
||||
'Warehouse Worker',
|
||||
'Retail Associate',
|
||||
'Host/Hostess',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final defaultPosition = {
|
||||
'title': '',
|
||||
'start_time': '',
|
||||
'end_time': '',
|
||||
'workers_needed': 1,
|
||||
'hourly_rate': 18.0,
|
||||
};
|
||||
|
||||
final defaults = {
|
||||
'date': '',
|
||||
'location': '',
|
||||
'recurring': false,
|
||||
'duration_days': null,
|
||||
'permanent': false,
|
||||
'duration_months': null,
|
||||
'positions': [Map<String, dynamic>.from(defaultPosition)],
|
||||
};
|
||||
|
||||
if (widget.initialData != null) {
|
||||
final input = widget.initialData!;
|
||||
final firstPosition = {
|
||||
...defaultPosition,
|
||||
'title': input['title'] ?? input['role'] ?? '',
|
||||
'start_time': input['startTime'] ?? input['start_time'] ?? '',
|
||||
'end_time': input['endTime'] ?? input['end_time'] ?? '',
|
||||
'hourly_rate': (input['hourlyRate'] ?? input['hourly_rate'] ?? 18.0)
|
||||
.toDouble(),
|
||||
'workers_needed': (input['workers'] ?? input['workers_needed'] ?? 1)
|
||||
.toInt(),
|
||||
};
|
||||
|
||||
_formData = {
|
||||
...defaults,
|
||||
...input,
|
||||
'positions': [firstPosition],
|
||||
};
|
||||
} else {
|
||||
_formData = Map.from(defaults);
|
||||
}
|
||||
|
||||
if (_formData['date'] == null || _formData['date'] == '') {
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
_formData['date'] = tomorrow.toIso8601String().split('T')[0];
|
||||
}
|
||||
}
|
||||
|
||||
void _updateField(String field, dynamic value) {
|
||||
setState(() {
|
||||
_formData[field] = value;
|
||||
});
|
||||
}
|
||||
|
||||
void _updatePositionField(int index, String field, dynamic value) {
|
||||
setState(() {
|
||||
_formData['positions'][index][field] = value;
|
||||
});
|
||||
}
|
||||
|
||||
void _addPosition() {
|
||||
setState(() {
|
||||
_formData['positions'].add({
|
||||
'title': '',
|
||||
'start_time': '',
|
||||
'end_time': '',
|
||||
'workers_needed': 1,
|
||||
'hourly_rate': 18.0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _removePosition(int index) {
|
||||
if (_formData['positions'].length > 1) {
|
||||
setState(() {
|
||||
_formData['positions'].removeAt(index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _getShiftType() {
|
||||
if (_formData['permanent'] == true ||
|
||||
_formData['duration_months'] != null) {
|
||||
return 'Long Term';
|
||||
}
|
||||
if (_formData['recurring'] == true || _formData['duration_days'] != null) {
|
||||
return 'Multi-Day';
|
||||
}
|
||||
return 'One Day';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.client_home.form;
|
||||
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: UiColors.border)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.initialData != null
|
||||
? i18n.edit_reorder
|
||||
: i18n.post_new,
|
||||
style: UiTypography.headline3m.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
UiIcons.close,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.initialData != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: UiConstants.space5,
|
||||
right: UiConstants.space5,
|
||||
bottom: UiConstants.space5,
|
||||
),
|
||||
child: Text(
|
||||
i18n.review_subtitle,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Shift Type Badge
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space5),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
border: Border.all(color: const Color(0xFFBFDBFE)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFF3B82F6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
_getShiftType(),
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: const Color(0xFF1D4ED8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
_buildLabel(i18n.date_label),
|
||||
UiTextField(
|
||||
hintText: i18n.date_hint,
|
||||
controller: TextEditingController(text: _formData['date']),
|
||||
readOnly: true,
|
||||
onTap: () async {
|
||||
final selectedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate:
|
||||
_formData['date'] != null &&
|
||||
_formData['date'].isNotEmpty
|
||||
? DateTime.parse(_formData['date'])
|
||||
: DateTime.now(),
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(
|
||||
const Duration(days: 365 * 5),
|
||||
),
|
||||
);
|
||||
if (selectedDate != null) {
|
||||
_updateField(
|
||||
'date',
|
||||
selectedDate.toIso8601String().split('T')[0],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
|
||||
_buildLabel(i18n.location_label),
|
||||
UiTextField(
|
||||
hintText: i18n.location_hint,
|
||||
controller: TextEditingController(
|
||||
text: _formData['location'],
|
||||
),
|
||||
onChanged: (value) => _updateField('location', value),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(i18n.positions_title, style: UiTypography.body1b),
|
||||
UiButton.text(
|
||||
onPressed: _addPosition,
|
||||
text: i18n.add_position,
|
||||
leadingIcon: UiIcons.add,
|
||||
size: UiButtonSize.small,
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
maximumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
...(_formData['positions'] as List).asMap().entries.map((
|
||||
entry,
|
||||
) {
|
||||
final index = entry.key;
|
||||
final position = entry.value;
|
||||
return _PositionCard(
|
||||
index: index,
|
||||
position: position,
|
||||
showDelete: _formData['positions'].length > 1,
|
||||
onDelete: () => _removePosition(index),
|
||||
roles: _roles,
|
||||
onUpdate: (field, value) =>
|
||||
_updatePositionField(index, field, value),
|
||||
labels: i18n,
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: UiConstants.space10),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: UiButton.primary(
|
||||
text: i18n.post_shift,
|
||||
onPressed: () => widget.onSubmit(_formData),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLabel(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
||||
child: Text(text, style: UiTypography.body2b),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PositionCard extends StatelessWidget {
|
||||
final int index;
|
||||
final Map<String, dynamic> position;
|
||||
final bool showDelete;
|
||||
final VoidCallback onDelete;
|
||||
final List<String> roles;
|
||||
final Function(String field, dynamic value) onUpdate;
|
||||
final dynamic labels;
|
||||
|
||||
const _PositionCard({
|
||||
required this.index,
|
||||
required this.position,
|
||||
required this.showDelete,
|
||||
required this.onDelete,
|
||||
required this.roles,
|
||||
required this.onUpdate,
|
||||
required this.labels,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space5),
|
||||
shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusLg),
|
||||
color: const Color(0xFFF8FAFC),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: UiColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text('Position ${index + 1}', style: UiTypography.body2b),
|
||||
],
|
||||
),
|
||||
if (showDelete)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
UiIcons.close,
|
||||
size: 18,
|
||||
color: UiColors.iconError,
|
||||
),
|
||||
onPressed: onDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Simplified for brevity in prototype-to-feature move
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: position['title'].isEmpty
|
||||
? null
|
||||
: position['title'],
|
||||
hint: Text(labels.role_hint),
|
||||
items: roles
|
||||
.map(
|
||||
(role) => DropdownMenuItem(value: role, child: Text(role)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) => onUpdate('title', value),
|
||||
decoration: InputDecoration(
|
||||
labelText: labels.role_label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: UiTextField(
|
||||
label: labels.start_time,
|
||||
controller: TextEditingController(
|
||||
text: position['start_time'],
|
||||
),
|
||||
onChanged: (v) => onUpdate('start_time', v),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: UiTextField(
|
||||
label: labels.end_time,
|
||||
controller: TextEditingController(
|
||||
text: position['end_time'],
|
||||
),
|
||||
onChanged: (v) => onUpdate('end_time', v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that displays spending insights for the client.
|
||||
class SpendingWidget extends StatelessWidget {
|
||||
/// The spending this week.
|
||||
final double weeklySpending;
|
||||
|
||||
/// The spending for the next 7 days.
|
||||
final double next7DaysSpending;
|
||||
|
||||
/// The number of shifts this week.
|
||||
final int weeklyShifts;
|
||||
|
||||
/// The number of scheduled shifts for next 7 days.
|
||||
final int next7DaysScheduled;
|
||||
|
||||
/// Creates a [SpendingWidget].
|
||||
const SpendingWidget({
|
||||
super.key,
|
||||
required this.weeklySpending,
|
||||
required this.next7DaysSpending,
|
||||
required this.weeklyShifts,
|
||||
required this.next7DaysScheduled,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.client_home;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
i18n.widgets.spending.toUpperCase(),
|
||||
style: UiTypography.footnote1b.textSecondary.copyWith(
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [UiColors.primary, Color(0xFF0830B8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.primary.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'This Week',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 9),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${weeklySpending.toStringAsFixed(0)}',
|
||||
style: UiTypography.headline3m.copyWith(
|
||||
color: UiColors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$weeklyShifts shifts',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Text(
|
||||
'Next 7 Days',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 9),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${next7DaysSpending.toStringAsFixed(0)}',
|
||||
style: UiTypography.headline4m.copyWith(
|
||||
color: UiColors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$next7DaysScheduled scheduled',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space3),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: Colors.white24)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.sparkles,
|
||||
color: UiColors.white,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'💡 ' +
|
||||
i18n.dashboard.insight_lightbulb(amount: '180'),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
i18n.dashboard.insight_tip,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
31
apps/mobile/packages/features/client/home/pubspec.yaml
Normal file
31
apps/mobile/packages/features/client/home/pubspec.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: client_home
|
||||
description: Home screen and dashboard for the client application.
|
||||
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.0
|
||||
equatable: ^2.0.5
|
||||
lucide_icons: ^0.257.0
|
||||
|
||||
design_system:
|
||||
path: ../../../design_system
|
||||
core_localization:
|
||||
path: ../../../core_localization
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
bloc_test: ^9.1.0
|
||||
mocktail: ^1.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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] that uses [BusinessRepositoryMock].
|
||||
///
|
||||
/// This class serves as a data adapter that bridges the domain repository interface
|
||||
/// with the backend data source provided by the `data_connect` package. It strictly
|
||||
/// delegates all operations to the [BusinessRepositoryMock], ensuring no business
|
||||
/// logic resides in the data layer.
|
||||
class HubRepositoryImpl implements HubRepositoryInterface {
|
||||
/// The business repository mock from data connect.
|
||||
final BusinessRepositoryMock mock;
|
||||
|
||||
/// Creates a [HubRepositoryImpl] instance.
|
||||
///
|
||||
/// Takes a [BusinessRepositoryMock] as a dependency to perform data operations.
|
||||
HubRepositoryImpl({required this.mock});
|
||||
|
||||
@override
|
||||
Future<List<Hub>> getHubs() {
|
||||
// In a production environment, the business ID would be retrieved from
|
||||
// a session manager or authentication state. For the current mock strategy,
|
||||
// we use a hardcoded value 'biz_1' to represent the active client.
|
||||
return mock.getHubs('biz_1');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Hub> createHub({required String name, required String address}) {
|
||||
// Delegates hub creation to the mock repository.
|
||||
return mock.createHub(businessId: 'biz_1', name: name, address: address);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteHub(String id) {
|
||||
// Delegates hub deletion to the mock repository.
|
||||
return mock.deleteHub(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> assignNfcTag({required String hubId, required String nfcTagId}) {
|
||||
// Delegates NFC tag assignment to the mock repository.
|
||||
return mock.assignNfcTag(hubId: hubId, nfcTagId: nfcTagId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Represents the arguments required for the AssignNfcTagUseCase.
|
||||
///
|
||||
/// Encapsulates the hub ID and the NFC tag ID to be assigned.
|
||||
class AssignNfcTagArguments extends UseCaseArgument {
|
||||
/// The unique identifier of the hub.
|
||||
final String hubId;
|
||||
|
||||
/// The unique identifier of the NFC tag.
|
||||
final String nfcTagId;
|
||||
|
||||
/// Creates an [AssignNfcTagArguments] instance.
|
||||
///
|
||||
/// Both [hubId] and [nfcTagId] are required.
|
||||
const AssignNfcTagArguments({required this.hubId, required this.nfcTagId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [hubId, nfcTagId];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Represents the arguments required for the CreateHubUseCase.
|
||||
///
|
||||
/// Encapsulates the name and address of the hub to be created.
|
||||
class CreateHubArguments extends UseCaseArgument {
|
||||
/// The name of the hub.
|
||||
final String name;
|
||||
|
||||
/// The physical address of the hub.
|
||||
final String address;
|
||||
|
||||
/// Creates a [CreateHubArguments] instance.
|
||||
///
|
||||
/// Both [name] and [address] are required.
|
||||
const CreateHubArguments({required this.name, required this.address});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, address];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Represents the arguments required for the DeleteHubUseCase.
|
||||
///
|
||||
/// Encapsulates the hub ID of the hub to be deleted.
|
||||
class DeleteHubArguments extends UseCaseArgument {
|
||||
/// The unique identifier of the hub to delete.
|
||||
final String hubId;
|
||||
|
||||
/// Creates a [DeleteHubArguments] instance.
|
||||
///
|
||||
/// The [hubId] is required.
|
||||
const DeleteHubArguments({required this.hubId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [hubId];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Interface for the Hub repository.
|
||||
///
|
||||
/// This repository defines the contract for hub-related operations in the
|
||||
/// domain layer. It handles fetching, creating, deleting hubs and assigning
|
||||
/// NFC tags. The implementation will be provided in the data layer.
|
||||
abstract interface class HubRepositoryInterface {
|
||||
/// Fetches the list of hubs for the current client.
|
||||
///
|
||||
/// Returns a list of [Hub] entities.
|
||||
Future<List<Hub>> getHubs();
|
||||
|
||||
/// Creates a new hub.
|
||||
///
|
||||
/// Takes the [name] and [address] of the new hub.
|
||||
/// Returns the created [Hub] entity.
|
||||
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.
|
||||
///
|
||||
/// Takes the [hubId] and the [nfcTagId] to be associated.
|
||||
Future<void> assignNfcTag({required String hubId, required String nfcTagId});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../arguments/assign_nfc_tag_arguments.dart';
|
||||
import '../repositories/hub_repository_interface.dart';
|
||||
|
||||
/// Use case for assigning an NFC tag to a hub.
|
||||
///
|
||||
/// This use case handles the association of a physical NFC tag with a specific
|
||||
/// hub by calling the [HubRepositoryInterface].
|
||||
class AssignNfcTagUseCase implements UseCase<AssignNfcTagArguments, void> {
|
||||
final HubRepositoryInterface _repository;
|
||||
|
||||
/// Creates an [AssignNfcTagUseCase].
|
||||
///
|
||||
/// Requires a [HubRepositoryInterface] to interact with the backend.
|
||||
AssignNfcTagUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<void> call(AssignNfcTagArguments arguments) {
|
||||
return _repository.assignNfcTag(
|
||||
hubId: arguments.hubId,
|
||||
nfcTagId: arguments.nfcTagId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../arguments/create_hub_arguments.dart';
|
||||
import '../repositories/hub_repository_interface.dart';
|
||||
|
||||
/// Use case for creating a new hub.
|
||||
///
|
||||
/// This use case orchestrates the creation of a hub by interacting with the
|
||||
/// [HubRepositoryInterface]. It requires [CreateHubArguments] which includes
|
||||
/// the name and address of the hub.
|
||||
class CreateHubUseCase implements UseCase<CreateHubArguments, Hub> {
|
||||
final HubRepositoryInterface _repository;
|
||||
|
||||
/// Creates a [CreateHubUseCase].
|
||||
///
|
||||
/// Requires a [HubRepositoryInterface] to perform the actual creation.
|
||||
CreateHubUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<Hub> call(CreateHubArguments arguments) {
|
||||
return _repository.createHub(
|
||||
name: arguments.name,
|
||||
address: arguments.address,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../arguments/delete_hub_arguments.dart';
|
||||
import '../repositories/hub_repository_interface.dart';
|
||||
|
||||
/// Use case for deleting a hub.
|
||||
///
|
||||
/// This use case removes a hub from the system via the [HubRepositoryInterface].
|
||||
class DeleteHubUseCase implements UseCase<DeleteHubArguments, void> {
|
||||
final HubRepositoryInterface _repository;
|
||||
|
||||
/// Creates a [DeleteHubUseCase].
|
||||
///
|
||||
/// Requires a [HubRepositoryInterface] to perform the deletion.
|
||||
DeleteHubUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<void> call(DeleteHubArguments arguments) {
|
||||
return _repository.deleteHub(arguments.hubId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
///
|
||||
/// This use case retrieves all hubs associated with the current client
|
||||
/// by interacting with the [HubRepositoryInterface].
|
||||
class GetHubsUseCase implements NoInputUseCase<List<Hub>> {
|
||||
final HubRepositoryInterface _repository;
|
||||
|
||||
/// Creates a [GetHubsUseCase].
|
||||
///
|
||||
/// Requires a [HubRepositoryInterface] to fetch the data.
|
||||
GetHubsUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<List<Hub>> call() {
|
||||
return _repository.getHubs();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import '../../domain/arguments/assign_nfc_tag_arguments.dart';
|
||||
import '../../domain/arguments/create_hub_arguments.dart';
|
||||
import '../../domain/arguments/delete_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 the state of the Client Hubs feature.
|
||||
///
|
||||
/// It orchestrates the flow between the UI and the domain layer by invoking
|
||||
/// specific use cases for fetching, creating, deleting, and assigning tags to 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);
|
||||
on<ClientHubsAddDialogToggled>(_onAddDialogToggled);
|
||||
on<ClientHubsIdentifyDialogToggled>(_onIdentifyDialogToggled);
|
||||
}
|
||||
|
||||
void _onAddDialogToggled(
|
||||
ClientHubsAddDialogToggled event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) {
|
||||
emit(state.copyWith(showAddHubDialog: event.visible));
|
||||
}
|
||||
|
||||
void _onIdentifyDialogToggled(
|
||||
ClientHubsIdentifyDialogToggled event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
) {
|
||||
if (event.hub == null) {
|
||||
emit(state.copyWith(clearHubToIdentify: true));
|
||||
} else {
|
||||
emit(state.copyWith(hubToIdentify: event.hub));
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
showAddHubDialog: false,
|
||||
),
|
||||
);
|
||||
} 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',
|
||||
clearHubToIdentify: true,
|
||||
),
|
||||
);
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.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();
|
||||
}
|
||||
|
||||
/// Event triggered to toggle the visibility of the "Add Hub" dialog.
|
||||
class ClientHubsAddDialogToggled extends ClientHubsEvent {
|
||||
final bool visible;
|
||||
|
||||
const ClientHubsAddDialogToggled({required this.visible});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [visible];
|
||||
}
|
||||
|
||||
/// Event triggered to toggle the visibility of the "Identify NFC" dialog.
|
||||
class ClientHubsIdentifyDialogToggled extends ClientHubsEvent {
|
||||
final Hub? hub;
|
||||
|
||||
const ClientHubsIdentifyDialogToggled({this.hub});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [hub];
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
|
||||
/// Whether the "Add Hub" dialog should be visible.
|
||||
final bool showAddHubDialog;
|
||||
|
||||
/// The hub currently being identified/assigned an NFC tag.
|
||||
/// If null, the identification dialog is closed.
|
||||
final Hub? hubToIdentify;
|
||||
|
||||
const ClientHubsState({
|
||||
this.status = ClientHubsStatus.initial,
|
||||
this.hubs = const [],
|
||||
this.errorMessage,
|
||||
this.successMessage,
|
||||
this.showAddHubDialog = false,
|
||||
this.hubToIdentify,
|
||||
});
|
||||
|
||||
ClientHubsState copyWith({
|
||||
ClientHubsStatus? status,
|
||||
List<Hub>? hubs,
|
||||
String? errorMessage,
|
||||
String? successMessage,
|
||||
bool? showAddHubDialog,
|
||||
Hub? hubToIdentify,
|
||||
bool clearHubToIdentify = false,
|
||||
}) {
|
||||
return ClientHubsState(
|
||||
status: status ?? this.status,
|
||||
hubs: hubs ?? this.hubs,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
successMessage: successMessage ?? this.successMessage,
|
||||
showAddHubDialog: showAddHubDialog ?? this.showAddHubDialog,
|
||||
hubToIdentify: clearHubToIdentify
|
||||
? null
|
||||
: (hubToIdentify ?? this.hubToIdentify),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
hubs,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
showAddHubDialog,
|
||||
hubToIdentify,
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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: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.
|
||||
///
|
||||
/// This page follows the KROW Clean Architecture by being a [StatelessWidget]
|
||||
/// and delegating all state management to the [ClientHubsBloc].
|
||||
class ClientHubsPage extends StatelessWidget {
|
||||
/// Creates a [ClientHubsPage].
|
||||
const ClientHubsPage({super.key});
|
||||
|
||||
@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: () =>
|
||||
BlocProvider.of<ClientHubsBloc>(context).add(
|
||||
const ClientHubsAddDialogToggled(
|
||||
visible: true,
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
...state.hubs.map(
|
||||
(hub) => HubCard(
|
||||
hub: hub,
|
||||
onNfcPressed: () =>
|
||||
BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(
|
||||
ClientHubsIdentifyDialogToggled(hub: hub),
|
||||
),
|
||||
onDeletePressed: () =>
|
||||
BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(ClientHubsDeleteRequested(hub.id)),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
const HubInfoCard(),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.showAddHubDialog)
|
||||
AddHubDialog(
|
||||
onCreate: (name, address) {
|
||||
BlocProvider.of<ClientHubsBloc>(context).add(
|
||||
ClientHubsAddRequested(name: name, address: address),
|
||||
);
|
||||
},
|
||||
onCancel: () => BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(const ClientHubsAddDialogToggled(visible: false)),
|
||||
),
|
||||
if (state.hubToIdentify != null)
|
||||
IdentifyNfcDialog(
|
||||
hub: state.hubToIdentify!,
|
||||
onAssign: (tagId) {
|
||||
BlocProvider.of<ClientHubsBloc>(context).add(
|
||||
ClientHubsNfcTagAssignRequested(
|
||||
hubId: state.hubToIdentify!.id,
|
||||
nfcTagId: tagId,
|
||||
),
|
||||
);
|
||||
},
|
||||
onCancel: () => BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(const ClientHubsIdentifyDialogToggled()),
|
||||
),
|
||||
if (state.status == ClientHubsStatus.actionInProgress)
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 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.withValues(alpha: 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: () => BlocProvider.of<ClientHubsBloc>(
|
||||
context,
|
||||
).add(const ClientHubsAddDialogToggled(visible: true)),
|
||||
text: t.client_hubs.add_hub,
|
||||
leadingIcon: LucideIcons.plus,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 40),
|
||||
maximumSize: Size.fromHeight(40),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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.withValues(alpha: 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.withValues(alpha: 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.withValues(alpha: 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.withValues(alpha: 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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.withValues(alpha: 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.withValues(alpha: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
37
apps/mobile/packages/features/client/hubs/pubspec.yaml
Normal file
37
apps/mobile/packages/features/client/hubs/pubspec.yaml
Normal 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
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'src/data/repositories_impl/settings_repository_impl.dart';
|
||||
import 'src/domain/repositories/settings_repository_interface.dart';
|
||||
import 'src/domain/usecases/sign_out_usecase.dart';
|
||||
import 'src/presentation/blocs/client_settings_bloc.dart';
|
||||
import 'src/presentation/pages/client_settings_page.dart';
|
||||
|
||||
/// A [Module] for the client settings feature.
|
||||
class ClientSettingsModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => [DataConnectModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<SettingsRepositoryInterface>(
|
||||
() => SettingsRepositoryImpl(mock: i.get<AuthRepositoryMock>()),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(SignOutUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.add<ClientSettingsBloc>(
|
||||
() => ClientSettingsBloc(signOutUseCase: i.get<SignOutUseCase>()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(r) {
|
||||
r.child('/', child: (_) => const ClientSettingsPage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import '../../domain/repositories/settings_repository_interface.dart';
|
||||
|
||||
/// Implementation of [SettingsRepositoryInterface].
|
||||
///
|
||||
/// This implementation delegates data access to the [AuthRepositoryMock]
|
||||
/// from the `data_connect` package.
|
||||
class SettingsRepositoryImpl implements SettingsRepositoryInterface {
|
||||
/// The auth mock from data connect.
|
||||
final AuthRepositoryMock mock;
|
||||
|
||||
/// Creates a [SettingsRepositoryImpl] with the required [mock].
|
||||
SettingsRepositoryImpl({required this.mock});
|
||||
|
||||
@override
|
||||
Future<void> signOut() {
|
||||
return mock.signOut();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/// Interface for the Client Settings repository.
|
||||
///
|
||||
/// This repository handles settings-related operations such as user sign out.
|
||||
abstract interface class SettingsRepositoryInterface {
|
||||
/// Signs out the current user from the application.
|
||||
Future<void> signOut();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/settings_repository_interface.dart';
|
||||
|
||||
/// Use case handles the user sign out process.
|
||||
///
|
||||
/// This use case delegates the sign out logic to the [SettingsRepositoryInterface].
|
||||
class SignOutUseCase implements NoInputUseCase<void> {
|
||||
final SettingsRepositoryInterface _repository;
|
||||
|
||||
/// Creates a [SignOutUseCase].
|
||||
///
|
||||
/// Requires a [SettingsRepositoryInterface] to perform the sign out operation.
|
||||
SignOutUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<void> call() {
|
||||
return _repository.signOut();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../domain/usecases/sign_out_usecase.dart';
|
||||
|
||||
part 'client_settings_event.dart';
|
||||
part 'client_settings_state.dart';
|
||||
|
||||
/// BLoC to manage client settings and profile state.
|
||||
class ClientSettingsBloc
|
||||
extends Bloc<ClientSettingsEvent, ClientSettingsState> {
|
||||
final SignOutUseCase _signOutUseCase;
|
||||
|
||||
ClientSettingsBloc({required SignOutUseCase signOutUseCase})
|
||||
: _signOutUseCase = signOutUseCase,
|
||||
super(const ClientSettingsInitial()) {
|
||||
on<ClientSettingsSignOutRequested>(_onSignOutRequested);
|
||||
}
|
||||
|
||||
Future<void> _onSignOutRequested(
|
||||
ClientSettingsSignOutRequested event,
|
||||
Emitter<ClientSettingsState> emit,
|
||||
) async {
|
||||
emit(const ClientSettingsLoading());
|
||||
try {
|
||||
await _signOutUseCase();
|
||||
emit(const ClientSettingsSignOutSuccess());
|
||||
} catch (e) {
|
||||
emit(ClientSettingsError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
part of 'client_settings_bloc.dart';
|
||||
|
||||
abstract class ClientSettingsEvent extends Equatable {
|
||||
const ClientSettingsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class ClientSettingsSignOutRequested extends ClientSettingsEvent {
|
||||
const ClientSettingsSignOutRequested();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
part of 'client_settings_bloc.dart';
|
||||
|
||||
abstract class ClientSettingsState extends Equatable {
|
||||
const ClientSettingsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class ClientSettingsInitial extends ClientSettingsState {
|
||||
const ClientSettingsInitial();
|
||||
}
|
||||
|
||||
class ClientSettingsLoading extends ClientSettingsState {
|
||||
const ClientSettingsLoading();
|
||||
}
|
||||
|
||||
class ClientSettingsSignOutSuccess extends ClientSettingsState {
|
||||
const ClientSettingsSignOutSuccess();
|
||||
}
|
||||
|
||||
class ClientSettingsError extends ClientSettingsState {
|
||||
final String message;
|
||||
|
||||
const ClientSettingsError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
/// Extension on [IModularNavigator] to provide strongly-typed navigation
|
||||
/// for the client settings feature.
|
||||
extension ClientSettingsNavigator on IModularNavigator {
|
||||
/// Navigates to the client settings page.
|
||||
void pushClientSettings() {
|
||||
pushNamed('/client/settings/');
|
||||
}
|
||||
|
||||
/// Navigates to the hubs page.
|
||||
void pushHubs() {
|
||||
pushNamed('/client/hubs');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 '../blocs/client_settings_bloc.dart';
|
||||
import '../widgets/client_settings_page/settings_actions.dart';
|
||||
import '../widgets/client_settings_page/settings_profile_header.dart';
|
||||
import '../widgets/client_settings_page/settings_quick_links.dart';
|
||||
|
||||
/// Page for client settings and profile management.
|
||||
///
|
||||
/// This page follows the KROW architecture by being a [StatelessWidget]
|
||||
/// and delegating its state management to [ClientSettingsBloc] and its
|
||||
/// UI sections to specialized sub-widgets.
|
||||
class ClientSettingsPage extends StatelessWidget {
|
||||
/// Creates a [ClientSettingsPage].
|
||||
const ClientSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ClientSettingsBloc>(
|
||||
create: (context) => Modular.get<ClientSettingsBloc>(),
|
||||
child: BlocListener<ClientSettingsBloc, ClientSettingsState>(
|
||||
listener: (context, state) {
|
||||
if (state is ClientSettingsSignOutSuccess) {
|
||||
Modular.to.navigate('/');
|
||||
}
|
||||
if (state is ClientSettingsError) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
}
|
||||
},
|
||||
child: const Scaffold(
|
||||
backgroundColor: UiColors.background,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SettingsProfileHeader(),
|
||||
SettingsActions(),
|
||||
SettingsQuickLinks(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 '../../blocs/client_settings_bloc.dart';
|
||||
|
||||
/// A widget that displays the primary actions for the settings page.
|
||||
class SettingsActions extends StatelessWidget {
|
||||
/// Creates a [SettingsActions].
|
||||
const SettingsActions({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = t.client_settings.profile;
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
UiButton.primary(text: labels.edit_profile, onPressed: () {}),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
UiButton.primary(text: labels.hubs, onPressed: () {}),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
BlocBuilder<ClientSettingsBloc, ClientSettingsState>(
|
||||
builder: (context, state) {
|
||||
return UiButton.secondary(
|
||||
text: labels.log_out,
|
||||
onPressed: state is ClientSettingsLoading
|
||||
? null
|
||||
: () => BlocProvider.of<ClientSettingsBloc>(
|
||||
context,
|
||||
).add(const ClientSettingsSignOutRequested()),
|
||||
);
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
/// A widget that displays the profile header with avatar and company info.
|
||||
class SettingsProfileHeader extends StatelessWidget {
|
||||
/// Creates a [SettingsProfileHeader].
|
||||
const SettingsProfileHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = t.client_settings.profile;
|
||||
|
||||
return SliverAppBar(
|
||||
backgroundColor: UiColors.primary,
|
||||
expandedHeight: 200,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [UiColors.primary, Color(0xFF0047FF)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: UiConstants.space5 * 2),
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: UiColors.white.withValues(alpha: 0.24),
|
||||
width: 4,
|
||||
),
|
||||
color: UiColors.white,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'C',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Text(
|
||||
'Your Company',
|
||||
style: UiTypography.body1b.copyWith(color: UiColors.white),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
UiIcons.mail,
|
||||
size: 14,
|
||||
color: UiColors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
'client@example.com',
|
||||
style: UiTypography.footnote1r.copyWith(
|
||||
color: UiColors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.arrowLeft, color: UiColors.white),
|
||||
onPressed: () => Modular.to.pop(),
|
||||
),
|
||||
title: Text(
|
||||
labels.title,
|
||||
style: UiTypography.body1b.copyWith(color: UiColors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import '../../navigation/client_settings_navigator.dart';
|
||||
|
||||
/// A widget that displays a list of quick links in a card.
|
||||
class SettingsQuickLinks extends StatelessWidget {
|
||||
/// Creates a [SettingsQuickLinks].
|
||||
const SettingsQuickLinks({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = t.client_settings.profile;
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: 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: [
|
||||
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.pushHubs(),
|
||||
),
|
||||
_QuickLinkItem(
|
||||
icon: UiIcons.building,
|
||||
title: labels.billing_payments,
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal widget for a single quick link 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: [
|
||||
Row(
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
37
apps/mobile/packages/features/client/settings/pubspec.yaml
Normal file
37
apps/mobile/packages/features/client/settings/pubspec.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
name: client_settings
|
||||
description: Settings and profile screen for the client application.
|
||||
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.0
|
||||
equatable: ^2.0.5
|
||||
lucide_icons: ^0.257.0
|
||||
|
||||
design_system:
|
||||
path: ../../../design_system
|
||||
core_localization:
|
||||
path: ../../../core_localization
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
bloc_test: ^9.1.0
|
||||
mocktail: ^1.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
Reference in New Issue
Block a user