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,34 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Implementation of [AuthRepositoryInterface].
|
||||
class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
final AuthRepositoryMock mock;
|
||||
|
||||
AuthRepositoryImpl({required this.mock});
|
||||
|
||||
@override
|
||||
Stream<User?> get currentUser => mock.currentUser;
|
||||
|
||||
/// Signs in with a phone number and returns a verification ID.
|
||||
@override
|
||||
Future<String?> signInWithPhone({required String phoneNumber}) {
|
||||
return mock.signInWithPhone(phoneNumber);
|
||||
}
|
||||
|
||||
/// Signs out the current user.
|
||||
@override
|
||||
Future<void> signOut() {
|
||||
return mock.signOut();
|
||||
}
|
||||
|
||||
/// Verifies an OTP code and returns the authenticated user.
|
||||
@override
|
||||
Future<User?> verifyOtp({
|
||||
required String verificationId,
|
||||
required String smsCode,
|
||||
}) {
|
||||
return mock.verifyOtp(verificationId, smsCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Represents the arguments required for the [SignInWithPhoneUseCase].
|
||||
///
|
||||
/// Encapsulates the phone number needed to initiate the sign-in process.
|
||||
class SignInWithPhoneArguments extends UseCaseArgument {
|
||||
/// The phone number to be used for sign-in or sign-up.
|
||||
final String phoneNumber;
|
||||
|
||||
/// Creates a [SignInWithPhoneArguments] instance.
|
||||
///
|
||||
/// The [phoneNumber] is required.
|
||||
const SignInWithPhoneArguments({required this.phoneNumber});
|
||||
|
||||
@override
|
||||
List<Object> get props => [phoneNumber];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Represents the arguments required for the [VerifyOtpUseCase].
|
||||
///
|
||||
/// Encapsulates the verification ID and the SMS code needed to verify
|
||||
/// a phone number during the authentication process.
|
||||
class VerifyOtpArguments extends UseCaseArgument {
|
||||
/// The unique identifier received after requesting an OTP.
|
||||
final String verificationId;
|
||||
|
||||
/// The one-time password (OTP) sent to the user's phone.
|
||||
final String smsCode;
|
||||
|
||||
/// Creates a [VerifyOtpArguments] instance.
|
||||
///
|
||||
/// Both [verificationId] and [smsCode] are required.
|
||||
const VerifyOtpArguments({
|
||||
required this.verificationId,
|
||||
required this.smsCode,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [verificationId, smsCode];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Interface for authentication repository.
|
||||
abstract interface class AuthRepositoryInterface {
|
||||
Stream<User?> get currentUser;
|
||||
|
||||
/// Signs in with a phone number and returns a verification ID.
|
||||
Future<String?> signInWithPhone({required String phoneNumber});
|
||||
|
||||
/// Verifies the OTP code and returns the authenticated user.
|
||||
Future<User?> verifyOtp({
|
||||
required String verificationId,
|
||||
required String smsCode,
|
||||
});
|
||||
|
||||
/// Signs out the current user.
|
||||
Future<void> signOut();
|
||||
// Future<Staff?> getStaffProfile(String userId); // Could be moved to a separate repository if needed, but useful here for routing logic.
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// Represents the authentication mode: either signing up or logging in.
|
||||
enum AuthMode {
|
||||
/// User is creating a new account.
|
||||
signup,
|
||||
|
||||
/// User is logging into an existing account.
|
||||
login,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../arguments/sign_in_with_phone_arguments.dart';
|
||||
import '../repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Use case for signing in with a phone number.
|
||||
///
|
||||
/// This use case delegates the sign-in logic to the [AuthRepositoryInterface].
|
||||
class SignInWithPhoneUseCase
|
||||
implements UseCase<SignInWithPhoneArguments, String?> {
|
||||
final AuthRepositoryInterface _repository;
|
||||
|
||||
/// Creates a [SignInWithPhoneUseCase].
|
||||
///
|
||||
/// Requires an [AuthRepositoryInterface] to interact with the authentication data source.
|
||||
SignInWithPhoneUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<String?> call(SignInWithPhoneArguments arguments) {
|
||||
return _repository.signInWithPhone(phoneNumber: arguments.phoneNumber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../arguments/verify_otp_arguments.dart';
|
||||
import '../repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Use case for verifying an OTP code.
|
||||
///
|
||||
/// This use case delegates the OTP verification logic to the [AuthRepositoryInterface].
|
||||
class VerifyOtpUseCase implements UseCase<VerifyOtpArguments, User?> {
|
||||
final AuthRepositoryInterface _repository;
|
||||
|
||||
/// Creates a [VerifyOtpUseCase].
|
||||
///
|
||||
/// Requires an [AuthRepositoryInterface] to interact with the authentication data source.
|
||||
VerifyOtpUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<User?> call(VerifyOtpArguments arguments) {
|
||||
return _repository.verifyOtp(
|
||||
verificationId: arguments.verificationId,
|
||||
smsCode: arguments.smsCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/arguments/sign_in_with_phone_arguments.dart';
|
||||
import '../../domain/arguments/verify_otp_arguments.dart';
|
||||
import '../../domain/usecases/sign_in_with_phone_usecase.dart';
|
||||
import '../../domain/usecases/verify_otp_usecase.dart';
|
||||
import 'auth_event.dart';
|
||||
import 'auth_state.dart';
|
||||
|
||||
/// BLoC responsible for handling authentication logic.
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
|
||||
/// The use case for signing in with a phone number.
|
||||
final SignInWithPhoneUseCase _signInUseCase;
|
||||
|
||||
/// The use case for verifying an OTP.
|
||||
final VerifyOtpUseCase _verifyOtpUseCase;
|
||||
|
||||
/// Creates an [AuthBloc].
|
||||
AuthBloc({
|
||||
required SignInWithPhoneUseCase signInUseCase,
|
||||
required VerifyOtpUseCase verifyOtpUseCase,
|
||||
}) : _signInUseCase = signInUseCase,
|
||||
_verifyOtpUseCase = verifyOtpUseCase,
|
||||
super(const AuthState()) {
|
||||
on<AuthSignInRequested>(_onSignInRequested);
|
||||
on<AuthOtpSubmitted>(_onOtpSubmitted);
|
||||
on<AuthErrorCleared>(_onErrorCleared);
|
||||
on<AuthOtpUpdated>(_onOtpUpdated);
|
||||
on<AuthPhoneUpdated>(_onPhoneUpdated);
|
||||
}
|
||||
|
||||
/// Clears any authentication error from the state.
|
||||
void _onErrorCleared(AuthErrorCleared event, Emitter<AuthState> emit) {
|
||||
emit(state.copyWith(status: AuthStatus.codeSent, errorMessage: null));
|
||||
}
|
||||
|
||||
/// Updates the internal OTP state without triggering a submission.
|
||||
void _onOtpUpdated(AuthOtpUpdated event, Emitter<AuthState> emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
otp: event.otp,
|
||||
status: AuthStatus.codeSent,
|
||||
errorMessage: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates the internal phone number state without triggering a submission.
|
||||
void _onPhoneUpdated(AuthPhoneUpdated event, Emitter<AuthState> emit) {
|
||||
emit(state.copyWith(phoneNumber: event.phoneNumber, errorMessage: null));
|
||||
}
|
||||
|
||||
/// Handles the sign-in request, initiating the phone authentication process.
|
||||
Future<void> _onSignInRequested(
|
||||
AuthSignInRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AuthStatus.loading,
|
||||
mode: event.mode,
|
||||
phoneNumber: event.phoneNumber,
|
||||
),
|
||||
);
|
||||
try {
|
||||
final String? verificationId = await _signInUseCase(
|
||||
SignInWithPhoneArguments(
|
||||
phoneNumber: event.phoneNumber ?? state.phoneNumber,
|
||||
),
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AuthStatus.codeSent,
|
||||
verificationId: verificationId,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(status: AuthStatus.error, errorMessage: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles OTP submission and verification.
|
||||
Future<void> _onOtpSubmitted(
|
||||
AuthOtpSubmitted event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: AuthStatus.loading));
|
||||
try {
|
||||
final User? user = await _verifyOtpUseCase(
|
||||
VerifyOtpArguments(
|
||||
verificationId: event.verificationId,
|
||||
smsCode: event.smsCode,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(status: AuthStatus.authenticated, user: user));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(status: AuthStatus.error, errorMessage: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Disposes the BLoC resources.
|
||||
@override
|
||||
void dispose() {
|
||||
close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
|
||||
|
||||
/// Abstract base class for all authentication events.
|
||||
abstract class AuthEvent extends Equatable {
|
||||
const AuthEvent();
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
/// Event for requesting a sign-in with a phone number.
|
||||
class AuthSignInRequested extends AuthEvent {
|
||||
/// The phone number provided by the user.
|
||||
final String? phoneNumber;
|
||||
|
||||
/// The authentication mode (login or signup).
|
||||
final AuthMode mode;
|
||||
|
||||
const AuthSignInRequested({this.phoneNumber, required this.mode});
|
||||
|
||||
@override
|
||||
List<Object> get props => [mode];
|
||||
}
|
||||
|
||||
/// Event for submitting an OTP (One-Time Password) for verification.
|
||||
///
|
||||
/// This event is dispatched after the user has received an OTP and
|
||||
/// submits it for verification.
|
||||
class AuthOtpSubmitted extends AuthEvent {
|
||||
/// The verification ID received after the phone number submission.
|
||||
final String verificationId;
|
||||
|
||||
/// The SMS code (OTP) entered by the user.
|
||||
final String smsCode;
|
||||
|
||||
const AuthOtpSubmitted({required this.verificationId, required this.smsCode});
|
||||
|
||||
@override
|
||||
List<Object> get props => [verificationId, smsCode];
|
||||
}
|
||||
|
||||
/// Event for clearing any authentication error in the state.
|
||||
class AuthErrorCleared extends AuthEvent {}
|
||||
|
||||
/// Event for updating the current draft OTP in the state.
|
||||
class AuthOtpUpdated extends AuthEvent {
|
||||
/// The current draft OTP.
|
||||
final String otp;
|
||||
|
||||
const AuthOtpUpdated(this.otp);
|
||||
|
||||
@override
|
||||
List<Object> get props => [otp];
|
||||
}
|
||||
|
||||
/// Event for updating the current draft phone number in the state.
|
||||
class AuthPhoneUpdated extends AuthEvent {
|
||||
/// The current draft phone number.
|
||||
final String phoneNumber;
|
||||
|
||||
const AuthPhoneUpdated(this.phoneNumber);
|
||||
|
||||
@override
|
||||
List<Object> get props => [phoneNumber];
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
|
||||
|
||||
/// Enum representing the current status of the authentication process.
|
||||
enum AuthStatus {
|
||||
/// Initial state, awaiting phone number entry.
|
||||
initial,
|
||||
|
||||
/// Authentication operation in progress.
|
||||
loading,
|
||||
|
||||
/// OTP has been sent, awaiting code verification.
|
||||
codeSent,
|
||||
|
||||
/// User has been successfully authenticated.
|
||||
authenticated,
|
||||
|
||||
/// An error occurred during the process.
|
||||
error,
|
||||
}
|
||||
|
||||
/// A unified state class for the authentication process.
|
||||
class AuthState extends Equatable {
|
||||
/// The current status of the authentication flow.
|
||||
final AuthStatus status;
|
||||
|
||||
/// The ID received from the authentication service, used to verify the OTP.
|
||||
final String? verificationId;
|
||||
|
||||
/// The authentication mode (login or signup).
|
||||
final AuthMode mode;
|
||||
|
||||
/// The current draft OTP entered by the user.
|
||||
final String otp;
|
||||
|
||||
/// The phone number entered by the user.
|
||||
final String phoneNumber;
|
||||
|
||||
/// A descriptive message for any error that occurred.
|
||||
final String? errorMessage;
|
||||
|
||||
/// The authenticated user's data (available when status is [AuthStatus.authenticated]).
|
||||
final User? user;
|
||||
|
||||
const AuthState({
|
||||
this.status = AuthStatus.initial,
|
||||
this.verificationId,
|
||||
this.mode = AuthMode.login,
|
||||
this.otp = '',
|
||||
this.phoneNumber = '',
|
||||
this.errorMessage,
|
||||
this.user,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
verificationId,
|
||||
mode,
|
||||
otp,
|
||||
phoneNumber,
|
||||
errorMessage,
|
||||
user,
|
||||
];
|
||||
|
||||
/// Convenient helper to check if the status is [AuthStatus.loading].
|
||||
bool get isLoading => status == AuthStatus.loading;
|
||||
|
||||
/// Convenient helper to check if the status is [AuthStatus.error].
|
||||
bool get hasError => status == AuthStatus.error;
|
||||
|
||||
/// Copies the state with optional new values.
|
||||
AuthState copyWith({
|
||||
AuthStatus? status,
|
||||
String? verificationId,
|
||||
AuthMode? mode,
|
||||
String? otp,
|
||||
String? phoneNumber,
|
||||
String? errorMessage,
|
||||
User? user,
|
||||
}) {
|
||||
return AuthState(
|
||||
status: status ?? this.status,
|
||||
verificationId: verificationId ?? this.verificationId,
|
||||
mode: mode ?? this.mode,
|
||||
otp: otp ?? this.otp,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'profile_setup_event.dart';
|
||||
import 'profile_setup_state.dart';
|
||||
|
||||
export 'profile_setup_event.dart';
|
||||
export 'profile_setup_state.dart';
|
||||
|
||||
/// BLoC responsible for managing the profile setup state and logic.
|
||||
class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState> {
|
||||
/// Creates a [ProfileSetupBloc] with an initial state.
|
||||
ProfileSetupBloc() : super(const ProfileSetupState()) {
|
||||
on<ProfileSetupFullNameChanged>(_onFullNameChanged);
|
||||
on<ProfileSetupBioChanged>(_onBioChanged);
|
||||
on<ProfileSetupLocationsChanged>(_onLocationsChanged);
|
||||
on<ProfileSetupDistanceChanged>(_onDistanceChanged);
|
||||
on<ProfileSetupSkillsChanged>(_onSkillsChanged);
|
||||
on<ProfileSetupIndustriesChanged>(_onIndustriesChanged);
|
||||
on<ProfileSetupSubmitted>(_onSubmitted);
|
||||
}
|
||||
|
||||
/// Handles the [ProfileSetupFullNameChanged] event.
|
||||
void _onFullNameChanged(
|
||||
ProfileSetupFullNameChanged event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
) {
|
||||
emit(state.copyWith(fullName: event.fullName));
|
||||
}
|
||||
|
||||
/// Handles the [ProfileSetupBioChanged] event.
|
||||
void _onBioChanged(
|
||||
ProfileSetupBioChanged event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
) {
|
||||
emit(state.copyWith(bio: event.bio));
|
||||
}
|
||||
|
||||
/// Handles the [ProfileSetupLocationsChanged] event.
|
||||
void _onLocationsChanged(
|
||||
ProfileSetupLocationsChanged event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
) {
|
||||
emit(state.copyWith(preferredLocations: event.locations));
|
||||
}
|
||||
|
||||
/// Handles the [ProfileSetupDistanceChanged] event.
|
||||
void _onDistanceChanged(
|
||||
ProfileSetupDistanceChanged event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
) {
|
||||
emit(state.copyWith(maxDistanceMiles: event.distance));
|
||||
}
|
||||
|
||||
/// Handles the [ProfileSetupSkillsChanged] event.
|
||||
void _onSkillsChanged(
|
||||
ProfileSetupSkillsChanged event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
) {
|
||||
emit(state.copyWith(skills: event.skills));
|
||||
}
|
||||
|
||||
/// Handles the [ProfileSetupIndustriesChanged] event.
|
||||
void _onIndustriesChanged(
|
||||
ProfileSetupIndustriesChanged event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
) {
|
||||
emit(state.copyWith(industries: event.industries));
|
||||
}
|
||||
|
||||
/// Handles the [ProfileSetupSubmitted] event.
|
||||
Future<void> _onSubmitted(
|
||||
ProfileSetupSubmitted event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ProfileSetupStatus.loading));
|
||||
|
||||
try {
|
||||
// In a real app, we would send this data to a UseCase
|
||||
debugPrint('Submitting Profile:');
|
||||
debugPrint('Name: ${state.fullName}');
|
||||
debugPrint('Bio: ${state.bio}');
|
||||
debugPrint('Locations: ${state.preferredLocations}');
|
||||
debugPrint('Distance: ${state.maxDistanceMiles}');
|
||||
debugPrint('Skills: ${state.skills}');
|
||||
debugPrint('Industries: ${state.industries}');
|
||||
|
||||
// Mocking profile creation delay
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
emit(state.copyWith(status: ProfileSetupStatus.success));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ProfileSetupStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Base class for all profile setup events.
|
||||
abstract class ProfileSetupEvent extends Equatable {
|
||||
const ProfileSetupEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Event triggered when the full name changes.
|
||||
class ProfileSetupFullNameChanged extends ProfileSetupEvent {
|
||||
/// The new full name value.
|
||||
final String fullName;
|
||||
|
||||
/// Creates a [ProfileSetupFullNameChanged] event.
|
||||
const ProfileSetupFullNameChanged(this.fullName);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [fullName];
|
||||
}
|
||||
|
||||
/// Event triggered when the bio changes.
|
||||
class ProfileSetupBioChanged extends ProfileSetupEvent {
|
||||
/// The new bio value.
|
||||
final String bio;
|
||||
|
||||
/// Creates a [ProfileSetupBioChanged] event.
|
||||
const ProfileSetupBioChanged(this.bio);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [bio];
|
||||
}
|
||||
|
||||
/// Event triggered when the preferred locations change.
|
||||
class ProfileSetupLocationsChanged extends ProfileSetupEvent {
|
||||
/// The new list of locations.
|
||||
final List<String> locations;
|
||||
|
||||
/// Creates a [ProfileSetupLocationsChanged] event.
|
||||
const ProfileSetupLocationsChanged(this.locations);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [locations];
|
||||
}
|
||||
|
||||
/// Event triggered when the max distance changes.
|
||||
class ProfileSetupDistanceChanged extends ProfileSetupEvent {
|
||||
/// The new max distance value in miles.
|
||||
final double distance;
|
||||
|
||||
/// Creates a [ProfileSetupDistanceChanged] event.
|
||||
const ProfileSetupDistanceChanged(this.distance);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [distance];
|
||||
}
|
||||
|
||||
/// Event triggered when the skills change.
|
||||
class ProfileSetupSkillsChanged extends ProfileSetupEvent {
|
||||
/// The new list of selected skills.
|
||||
final List<String> skills;
|
||||
|
||||
/// Creates a [ProfileSetupSkillsChanged] event.
|
||||
const ProfileSetupSkillsChanged(this.skills);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [skills];
|
||||
}
|
||||
|
||||
/// Event triggered when the industries change.
|
||||
class ProfileSetupIndustriesChanged extends ProfileSetupEvent {
|
||||
/// The new list of selected industries.
|
||||
final List<String> industries;
|
||||
|
||||
/// Creates a [ProfileSetupIndustriesChanged] event.
|
||||
const ProfileSetupIndustriesChanged(this.industries);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [industries];
|
||||
}
|
||||
|
||||
/// Event triggered when the profile submission is requested.
|
||||
class ProfileSetupSubmitted extends ProfileSetupEvent {
|
||||
/// Creates a [ProfileSetupSubmitted] event.
|
||||
const ProfileSetupSubmitted();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Enum defining the status of the profile setup process.
|
||||
enum ProfileSetupStatus { initial, loading, success, failure }
|
||||
|
||||
/// State for the ProfileSetupBloc.
|
||||
class ProfileSetupState extends Equatable {
|
||||
/// The user's full name.
|
||||
final String fullName;
|
||||
|
||||
/// The user's bio or short description.
|
||||
final String bio;
|
||||
|
||||
/// List of preferred work locations (e.g., cities, zip codes).
|
||||
final List<String> preferredLocations;
|
||||
|
||||
/// Maximum distance in miles the user is willing to travel.
|
||||
final double maxDistanceMiles;
|
||||
|
||||
/// List of skills selected by the user.
|
||||
final List<String> skills;
|
||||
|
||||
/// List of industries selected by the user.
|
||||
final List<String> industries;
|
||||
|
||||
/// The current status of the profile setup process.
|
||||
final ProfileSetupStatus status;
|
||||
|
||||
/// Error message if the status is [ProfileSetupStatus.failure].
|
||||
final String? errorMessage;
|
||||
|
||||
/// Creates a [ProfileSetupState] instance.
|
||||
const ProfileSetupState({
|
||||
this.fullName = '',
|
||||
this.bio = '',
|
||||
this.preferredLocations = const [],
|
||||
this.maxDistanceMiles = 25,
|
||||
this.skills = const [],
|
||||
this.industries = const [],
|
||||
this.status = ProfileSetupStatus.initial,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// Creates a copy of the current state with updated values.
|
||||
ProfileSetupState copyWith({
|
||||
String? fullName,
|
||||
String? bio,
|
||||
List<String>? preferredLocations,
|
||||
double? maxDistanceMiles,
|
||||
List<String>? skills,
|
||||
List<String>? industries,
|
||||
ProfileSetupStatus? status,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return ProfileSetupState(
|
||||
fullName: fullName ?? this.fullName,
|
||||
bio: bio ?? this.bio,
|
||||
preferredLocations: preferredLocations ?? this.preferredLocations,
|
||||
maxDistanceMiles: maxDistanceMiles ?? this.maxDistanceMiles,
|
||||
skills: skills ?? this.skills,
|
||||
industries: industries ?? this.industries,
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
fullName,
|
||||
bio,
|
||||
preferredLocations,
|
||||
maxDistanceMiles,
|
||||
skills,
|
||||
industries,
|
||||
status,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import '../../domain/ui_entities/auth_mode.dart';
|
||||
|
||||
/// Extension on [IModularNavigator] to provide strongly-typed navigation
|
||||
/// for the staff authentication feature.
|
||||
extension AuthNavigator on IModularNavigator {
|
||||
/// Navigates to the phone verification page.
|
||||
void pushPhoneVerification(AuthMode mode) {
|
||||
pushNamed('./phone-verification', arguments: {'mode': mode.name});
|
||||
}
|
||||
|
||||
/// Navigates to the profile setup page, replacing the current route.
|
||||
void pushReplacementProfileSetup() {
|
||||
pushReplacementNamed('./profile-setup');
|
||||
}
|
||||
|
||||
/// Navigates to the worker home (external to this module).
|
||||
void pushWorkerHome() {
|
||||
pushNamed('/worker-home');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
|
||||
import '../navigation/auth_navigator.dart'; // Import the extension
|
||||
import '../widgets/get_started_page/get_started_actions.dart';
|
||||
import '../widgets/get_started_page/get_started_background.dart';
|
||||
import '../widgets/get_started_page/get_started_header.dart';
|
||||
|
||||
/// The entry point page for staff authentication.
|
||||
///
|
||||
/// This page provides the user with the initial options to either sign up
|
||||
/// for a new account or log in to an existing one. It uses a series of
|
||||
/// sub-widgets to maintain a clean and modular structure.
|
||||
class GetStartedPage extends StatelessWidget {
|
||||
/// Creates a [GetStartedPage].
|
||||
const GetStartedPage({super.key});
|
||||
|
||||
/// On sign up pressed callback.
|
||||
void onSignUpPressed() {
|
||||
Modular.to.pushPhoneVerification(AuthMode.signup);
|
||||
}
|
||||
|
||||
/// On login pressed callback.
|
||||
void onLoginPressed() {
|
||||
Modular.to.pushPhoneVerification(AuthMode.login);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Background
|
||||
const Expanded(child: GetStartedBackground()),
|
||||
|
||||
// Content Overlay
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Main text and actions
|
||||
const GetStartedHeader(),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Actions
|
||||
GetStartedActions(
|
||||
onSignUpPressed: onSignUpPressed,
|
||||
onLoginPressed: onLoginPressed,
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_event.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_state.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
|
||||
import '../widgets/phone_verification_page/phone_input.dart';
|
||||
import '../widgets/phone_verification_page/otp_verification.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
import '../navigation/auth_navigator.dart'; // Import the extension
|
||||
|
||||
/// A combined page for phone number entry and OTP verification.
|
||||
///
|
||||
/// This page coordinates the authentication flow by switching between
|
||||
/// [PhoneInput] and [OtpVerification] based on the current [AuthState].
|
||||
class PhoneVerificationPage extends StatelessWidget {
|
||||
/// The authentication mode (login or signup).
|
||||
final AuthMode mode;
|
||||
|
||||
/// Creates a [PhoneVerificationPage].
|
||||
const PhoneVerificationPage({super.key, required this.mode});
|
||||
|
||||
/// Handles the request to send a verification code to the provided phone number.
|
||||
void _onSendCode({
|
||||
required BuildContext context,
|
||||
required String phoneNumber,
|
||||
}) {
|
||||
if (phoneNumber.length == 10) {
|
||||
BlocProvider.of<AuthBloc>(
|
||||
context,
|
||||
).add(AuthSignInRequested(phoneNumber: '+1$phoneNumber', mode: mode));
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.staff_authentication.phone_verification_page.validation_error,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the submission of the OTP code.
|
||||
void _onOtpSubmitted({
|
||||
required BuildContext context,
|
||||
required String otp,
|
||||
required String verificationId,
|
||||
}) {
|
||||
BlocProvider.of<AuthBloc>(
|
||||
context,
|
||||
).add(AuthOtpSubmitted(verificationId: verificationId, smsCode: otp));
|
||||
}
|
||||
|
||||
/// Handles the request to resend the verification code using the phone number in the state.
|
||||
void _onResend({required BuildContext context}) {
|
||||
BlocProvider.of<AuthBloc>(context).add(AuthSignInRequested(mode: mode));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AuthBloc>(
|
||||
create: (context) => Modular.get<AuthBloc>(),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == AuthStatus.authenticated) {
|
||||
if (state.mode == AuthMode.signup) {
|
||||
Modular.to.pushReplacementProfileSetup();
|
||||
} else {
|
||||
Modular.to.pushWorkerHome();
|
||||
}
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
// Check if we are in the OTP step
|
||||
final bool isOtpStep =
|
||||
state.status == AuthStatus.codeSent ||
|
||||
(state.status == AuthStatus.error &&
|
||||
state.verificationId != null) ||
|
||||
(state.status == AuthStatus.loading &&
|
||||
state.verificationId != null);
|
||||
|
||||
return Scaffold(
|
||||
appBar: const UiAppBar(
|
||||
centerTitle: true,
|
||||
showBackButton: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: isOtpStep
|
||||
? OtpVerification(
|
||||
state: state,
|
||||
onOtpSubmitted: (otp) => _onOtpSubmitted(
|
||||
context: context,
|
||||
otp: otp,
|
||||
verificationId: state.verificationId ?? '',
|
||||
),
|
||||
onResend: () => _onResend(context: context),
|
||||
onContinue: () => _onOtpSubmitted(
|
||||
context: context,
|
||||
otp: state.otp,
|
||||
verificationId: state.verificationId ?? '',
|
||||
),
|
||||
)
|
||||
: PhoneInput(
|
||||
state: state,
|
||||
onSendCode: () => _onSendCode(
|
||||
context: context,
|
||||
phoneNumber: state.phoneNumber,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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'
|
||||
hide ModularWatchExtension;
|
||||
import '../blocs/profile_setup/profile_setup_bloc.dart';
|
||||
import '../widgets/profile_setup_page/profile_setup_basic_info.dart';
|
||||
import '../widgets/profile_setup_page/profile_setup_location.dart';
|
||||
import '../widgets/profile_setup_page/profile_setup_experience.dart';
|
||||
import '../widgets/profile_setup_page/profile_setup_header.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
import '../navigation/auth_navigator.dart'; // Import the extension
|
||||
|
||||
/// Page for setting up the user profile after authentication.
|
||||
class ProfileSetupPage extends StatefulWidget {
|
||||
const ProfileSetupPage({super.key});
|
||||
|
||||
@override
|
||||
State<ProfileSetupPage> createState() => _ProfileSetupPageState();
|
||||
}
|
||||
|
||||
class _ProfileSetupPageState extends State<ProfileSetupPage> {
|
||||
/// Current step index.
|
||||
int _currentStep = 0;
|
||||
|
||||
/// List of steps in the profile setup process.
|
||||
List<Map<String, dynamic>> get _steps => [
|
||||
{
|
||||
'id': 'basic',
|
||||
'title': t.staff_authentication.profile_setup_page.steps.basic,
|
||||
'icon': UiIcons.user,
|
||||
},
|
||||
{
|
||||
'id': 'location',
|
||||
'title': t.staff_authentication.profile_setup_page.steps.location,
|
||||
'icon': UiIcons.mapPin,
|
||||
},
|
||||
{
|
||||
'id': 'experience',
|
||||
'title': t.staff_authentication.profile_setup_page.steps.experience,
|
||||
'icon': UiIcons.briefcase,
|
||||
},
|
||||
];
|
||||
|
||||
/// Handles the "Next" button tap logic.
|
||||
void _handleNext({
|
||||
required BuildContext context,
|
||||
required ProfileSetupState state,
|
||||
required int stepsCount,
|
||||
}) {
|
||||
if (_currStepValid(state: state)) {
|
||||
if (_currentStep < stepsCount - 1) {
|
||||
setState(() => _currentStep++);
|
||||
} else {
|
||||
BlocProvider.of<ProfileSetupBloc>(
|
||||
context,
|
||||
).add(const ProfileSetupSubmitted());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the "Back" button tap logic.
|
||||
void _handleBack() {
|
||||
if (_currentStep > 0) {
|
||||
setState(() => _currentStep--);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the current step is valid.
|
||||
bool _currStepValid({required ProfileSetupState state}) {
|
||||
switch (_currentStep) {
|
||||
case 0:
|
||||
return state.fullName.trim().length >= 2;
|
||||
case 1:
|
||||
return state.preferredLocations.isNotEmpty;
|
||||
case 2:
|
||||
return state.skills.isNotEmpty;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
/// Builds the profile setup page UI.
|
||||
Widget build(BuildContext context) {
|
||||
final steps = _steps;
|
||||
|
||||
// Calculate progress
|
||||
final double progress = (_currentStep + 1) / steps.length;
|
||||
|
||||
return BlocProvider<ProfileSetupBloc>(
|
||||
create: (context) => Modular.get<ProfileSetupBloc>(),
|
||||
child: BlocConsumer<ProfileSetupBloc, ProfileSetupState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == ProfileSetupStatus.success) {
|
||||
Modular.to.pushWorkerHome();
|
||||
} else if (state.status == ProfileSetupStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
state.errorMessage ??
|
||||
t.staff_authentication.profile_setup_page.error_occurred,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final isCreatingProfile = state.status == ProfileSetupStatus.loading;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Progress Bar
|
||||
LinearProgressIndicator(value: progress),
|
||||
|
||||
// Header (Back + Step Count)
|
||||
ProfileSetupHeader(
|
||||
currentStep: _currentStep,
|
||||
totalSteps: steps.length,
|
||||
onBackTap: _handleBack,
|
||||
),
|
||||
|
||||
// Step Indicators
|
||||
UiStepIndicator(
|
||||
stepIcons: steps
|
||||
.map((step) => step['icon'] as IconData)
|
||||
.toList(),
|
||||
currentStep: _currentStep,
|
||||
),
|
||||
|
||||
// Content Area
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: SingleChildScrollView(
|
||||
key: ValueKey<int>(_currentStep),
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
child: _buildStepContent(
|
||||
context: context,
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Footer
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: UiColors.separatorSecondary),
|
||||
),
|
||||
),
|
||||
child: isCreatingProfile
|
||||
? ElevatedButton(
|
||||
onPressed: null,
|
||||
child: const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: UiButton.primary(
|
||||
text: _currentStep == steps.length - 1
|
||||
? t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.complete_setup_button
|
||||
: t.common.continue_text,
|
||||
trailingIcon: _currentStep < steps.length - 1
|
||||
? UiIcons.arrowRight
|
||||
: null,
|
||||
onPressed: _currStepValid(state: state)
|
||||
? () => _handleNext(
|
||||
context: context,
|
||||
state: state,
|
||||
stepsCount: steps.length,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the content for the current step.
|
||||
Widget _buildStepContent({
|
||||
required BuildContext context,
|
||||
required ProfileSetupState state,
|
||||
}) {
|
||||
switch (_currentStep) {
|
||||
case 0:
|
||||
return ProfileSetupBasicInfo(
|
||||
fullName: state.fullName,
|
||||
bio: state.bio,
|
||||
onFullNameChanged: (val) => BlocProvider.of<ProfileSetupBloc>(
|
||||
context,
|
||||
).add(ProfileSetupFullNameChanged(val)),
|
||||
onBioChanged: (val) => BlocProvider.of<ProfileSetupBloc>(
|
||||
context,
|
||||
).add(ProfileSetupBioChanged(val)),
|
||||
);
|
||||
case 1:
|
||||
return ProfileSetupLocation(
|
||||
preferredLocations: state.preferredLocations,
|
||||
maxDistanceMiles: state.maxDistanceMiles,
|
||||
onLocationsChanged: (val) => BlocProvider.of<ProfileSetupBloc>(
|
||||
context,
|
||||
).add(ProfileSetupLocationsChanged(val)),
|
||||
onDistanceChanged: (val) => BlocProvider.of<ProfileSetupBloc>(
|
||||
context,
|
||||
).add(ProfileSetupDistanceChanged(val)),
|
||||
);
|
||||
case 2:
|
||||
return ProfileSetupExperience(
|
||||
skills: state.skills,
|
||||
industries: state.industries,
|
||||
onSkillsChanged: (val) => BlocProvider.of<ProfileSetupBloc>(
|
||||
context,
|
||||
).add(ProfileSetupSkillsChanged(val)),
|
||||
onIndustriesChanged: (val) => BlocProvider.of<ProfileSetupBloc>(
|
||||
context,
|
||||
).add(ProfileSetupIndustriesChanged(val)),
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A common widget that displays a "Having trouble? Contact Support" link.
|
||||
class AuthTroubleLink extends StatelessWidget {
|
||||
/// Creates an [AuthTroubleLink].
|
||||
const AuthTroubleLink({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
Text(
|
||||
t.staff_authentication.common.trouble_question,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
Text(
|
||||
t.staff_authentication.common.contact_support,
|
||||
style: UiTypography.body2b.textLink,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget for displaying a section title and subtitle
|
||||
class SectionTitleSubtitle extends StatelessWidget {
|
||||
/// The title of the section
|
||||
final String title;
|
||||
|
||||
/// The subtitle of the section
|
||||
final String subtitle;
|
||||
|
||||
const SectionTitleSubtitle({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
// Title
|
||||
Text(title, style: UiTypography.headline1m),
|
||||
|
||||
// Subtitle
|
||||
Text(subtitle, style: UiTypography.body2r.textSecondary),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget that displays the primary action buttons (Sign Up and Log In)
|
||||
/// for the Get Started page.
|
||||
class GetStartedActions extends StatelessWidget {
|
||||
/// Void callback for when the Sign Up button is pressed.
|
||||
final VoidCallback onSignUpPressed;
|
||||
|
||||
/// Void callback for when the Log In button is pressed.
|
||||
final VoidCallback onLoginPressed;
|
||||
|
||||
/// Creates a [GetStartedActions].
|
||||
const GetStartedActions({
|
||||
super.key,
|
||||
required this.onSignUpPressed,
|
||||
required this.onLoginPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Sign Up Button
|
||||
UiButton.primary(
|
||||
text: t.staff_authentication.get_started_page.sign_up_button,
|
||||
onPressed: onSignUpPressed,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Log In Button
|
||||
UiButton.secondary(
|
||||
text: t.staff_authentication.get_started_page.log_in_button,
|
||||
onPressed: onLoginPressed,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that displays the background for the Get Started page.
|
||||
class GetStartedBackground extends StatelessWidget {
|
||||
/// Creates a [GetStartedBackground].
|
||||
const GetStartedBackground({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// Logo
|
||||
Image.asset(UiImageAssets.logoBlue, height: 40),
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Hero Image
|
||||
Container(
|
||||
width: 288,
|
||||
height: 288,
|
||||
margin: const EdgeInsets.only(bottom: 32),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: UiColors.secondaryForeground.withAlpha(
|
||||
64,
|
||||
), // 0.5 opacity
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget that displays the welcome text and description on the Get Started page.
|
||||
class GetStartedHeader extends StatelessWidget {
|
||||
/// Creates a [GetStartedHeader].
|
||||
const GetStartedHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: UiTypography.displayM,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: t.staff_authentication.get_started_page.title_part1,
|
||||
),
|
||||
TextSpan(
|
||||
text: t.staff_authentication.get_started_page.title_part2,
|
||||
style: UiTypography.displayMb.textLink,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
t.staff_authentication.get_started_page.subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body1r.textSecondary,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_state.dart';
|
||||
import 'otp_verification/otp_input_field.dart';
|
||||
import 'otp_verification/otp_resend_section.dart';
|
||||
import 'otp_verification/otp_verification_actions.dart';
|
||||
import 'otp_verification/otp_verification_header.dart';
|
||||
|
||||
/// A widget that displays the OTP verification UI.
|
||||
class OtpVerification extends StatelessWidget {
|
||||
/// The current state of the authentication process.
|
||||
final AuthState state;
|
||||
|
||||
/// Callback for when the OTP is submitted.
|
||||
final ValueChanged<String> onOtpSubmitted;
|
||||
|
||||
/// Callback for when a new code is requested.
|
||||
final VoidCallback onResend;
|
||||
|
||||
/// Callback for the "Continue" action.
|
||||
final VoidCallback onContinue;
|
||||
|
||||
/// Creates an [OtpVerification].
|
||||
const OtpVerification({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onOtpSubmitted,
|
||||
required this.onResend,
|
||||
required this.onContinue,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space6,
|
||||
vertical: UiConstants.space8,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OtpVerificationHeader(phoneNumber: state.phoneNumber),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
OtpInputField(
|
||||
error: state.errorMessage ?? '',
|
||||
onCompleted: onOtpSubmitted,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
OtpResendSection(onResend: onResend, hasError: state.hasError),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
OtpVerificationActions(
|
||||
isLoading: state.isLoading,
|
||||
canSubmit: state.otp.length == 6,
|
||||
onContinue: onContinue,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../blocs/auth_event.dart';
|
||||
import '../../../blocs/auth_bloc.dart';
|
||||
|
||||
/// A widget that displays a 6-digit OTP input field.
|
||||
///
|
||||
/// This widget handles its own internal [TextEditingController]s and focus nodes.
|
||||
/// It dispatches [AuthOtpUpdated] to the [AuthBloc] on every change.
|
||||
class OtpInputField extends StatefulWidget {
|
||||
/// Callback for when the OTP code is fully entered (6 digits).
|
||||
final ValueChanged<String> onCompleted;
|
||||
|
||||
/// The error message to display, if any.
|
||||
final String error;
|
||||
|
||||
/// Creates an [OtpInputField].
|
||||
const OtpInputField({
|
||||
super.key,
|
||||
required this.onCompleted,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OtpInputField> createState() => _OtpInputFieldState();
|
||||
}
|
||||
|
||||
class _OtpInputFieldState extends State<OtpInputField> {
|
||||
final List<TextEditingController> _controllers = List.generate(
|
||||
6,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _controllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final node in _focusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Helper getter to compute the current OTP code from all controllers.
|
||||
String get _otpCode => _controllers.map((c) => c.text).join();
|
||||
|
||||
/// Handles changes to the OTP input fields.
|
||||
void _onChanged({
|
||||
required BuildContext context,
|
||||
required int index,
|
||||
required String value,
|
||||
}) {
|
||||
if (value.length == 1 && index < 5) {
|
||||
_focusNodes[index + 1].requestFocus();
|
||||
}
|
||||
|
||||
// Notify the Bloc of the change
|
||||
BlocProvider.of<AuthBloc>(context).add(AuthOtpUpdated(_otpCode));
|
||||
|
||||
if (_otpCode.length == 6) {
|
||||
widget.onCompleted(_otpCode);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(6, (index) {
|
||||
return SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: TextField(
|
||||
controller: _controllers[index],
|
||||
focusNode: _focusNodes[index],
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
maxLength: 1,
|
||||
style: UiTypography.headline3m,
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: widget.error.isNotEmpty
|
||||
? UiColors.textError
|
||||
: (_controllers[index].text.isNotEmpty
|
||||
? UiColors.primary
|
||||
: UiColors.border),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
borderSide: BorderSide(
|
||||
color: widget.error.isNotEmpty
|
||||
? UiColors.textError
|
||||
: (_controllers[index].text.isNotEmpty
|
||||
? UiColors.primary
|
||||
: UiColors.border),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
onChanged: (value) =>
|
||||
_onChanged(context: context, index: index, value: value),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (widget.error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space4),
|
||||
child: Center(
|
||||
child: Text(widget.error, style: UiTypography.body2r.textError),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget that handles the OTP resend logic and countdown timer.
|
||||
class OtpResendSection extends StatefulWidget {
|
||||
/// Callback for when the resend link is pressed.
|
||||
final VoidCallback onResend;
|
||||
|
||||
/// Whether an error is currently displayed. (Used for layout tweaks in the original code)
|
||||
final bool hasError;
|
||||
|
||||
/// Creates an [OtpResendSection].
|
||||
const OtpResendSection({
|
||||
super.key,
|
||||
required this.onResend,
|
||||
this.hasError = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OtpResendSection> createState() => _OtpResendSectionState();
|
||||
}
|
||||
|
||||
class _OtpResendSectionState extends State<OtpResendSection> {
|
||||
int _countdown = 30;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startCountdown();
|
||||
}
|
||||
|
||||
/// Starts the countdown timer.
|
||||
void _startCountdown() {
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted && _countdown > 0) {
|
||||
setState(() => _countdown--);
|
||||
_startCountdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: widget.hasError
|
||||
? ''
|
||||
: '${t.staff_authentication.otp_verification.did_not_get_code} ',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
WidgetSpan(
|
||||
child: GestureDetector(
|
||||
onTap: _countdown > 0 ? null : widget.onResend,
|
||||
child: Text(
|
||||
_countdown > 0
|
||||
? t.staff_authentication.otp_verification.resend_in(
|
||||
seconds: _countdown.toString(),
|
||||
)
|
||||
: t.staff_authentication.otp_verification.resend_code,
|
||||
style: (_countdown > 0
|
||||
? UiTypography.body2r.textSecondary
|
||||
: UiTypography.body2b.textPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
import '../../common/auth_trouble_link.dart';
|
||||
|
||||
/// A widget that displays the primary action button and trouble link for OTP verification.
|
||||
class OtpVerificationActions extends StatelessWidget {
|
||||
/// Whether the verification process is currently loading.
|
||||
final bool isLoading;
|
||||
|
||||
/// Whether the submit button should be enabled.
|
||||
final bool canSubmit;
|
||||
|
||||
/// Callback for when the Continue button is pressed.
|
||||
final VoidCallback? onContinue;
|
||||
|
||||
/// Creates an [OtpVerificationActions].
|
||||
const OtpVerificationActions({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.canSubmit,
|
||||
this.onContinue,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: UiColors.separatorSecondary, width: 1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
isLoading
|
||||
? ElevatedButton(
|
||||
onPressed: null,
|
||||
child: const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: UiButton.primary(
|
||||
text: t.common.continue_text,
|
||||
onPressed: canSubmit ? onContinue : null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const AuthTroubleLink(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget that displays the title and subtitle for the OTP Verification page.
|
||||
class OtpVerificationHeader extends StatelessWidget {
|
||||
/// The phone number to which the code was sent.
|
||||
final String phoneNumber;
|
||||
|
||||
/// Creates an [OtpVerificationHeader].
|
||||
const OtpVerificationHeader({super.key, required this.phoneNumber});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.staff_authentication.phone_verification_page.enter_code_title,
|
||||
style: UiTypography.headline1m,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: t
|
||||
.staff_authentication
|
||||
.phone_verification_page
|
||||
.code_sent_message,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
children: [
|
||||
TextSpan(text: '+1 $phoneNumber', style: UiTypography.body2b),
|
||||
TextSpan(
|
||||
text: t
|
||||
.staff_authentication
|
||||
.phone_verification_page
|
||||
.code_sent_instruction,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_event.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_state.dart';
|
||||
import 'phone_input/phone_input_actions.dart';
|
||||
import 'phone_input/phone_input_form_field.dart';
|
||||
import 'phone_input/phone_input_header.dart';
|
||||
|
||||
/// A widget that displays the phone number entry UI.
|
||||
class PhoneInput extends StatelessWidget {
|
||||
/// The current state of the authentication process.
|
||||
final AuthState state;
|
||||
|
||||
/// Callback for when the "Send Code" action is triggered.
|
||||
final VoidCallback onSendCode;
|
||||
|
||||
/// Creates a [PhoneInput].
|
||||
const PhoneInput({super.key, required this.state, required this.onSendCode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space6,
|
||||
vertical: UiConstants.space8,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const PhoneInputHeader(),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
PhoneInputFormField(
|
||||
initialValue: state.phoneNumber,
|
||||
error: state.errorMessage ?? '',
|
||||
onChanged: (value) {
|
||||
BlocProvider.of<AuthBloc>(
|
||||
context,
|
||||
).add(AuthPhoneUpdated(value));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
PhoneInputActions(isLoading: state.isLoading, onSendCode: onSendCode),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/common/auth_trouble_link.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget that displays the primary action button and trouble link for Phone Input.
|
||||
class PhoneInputActions extends StatelessWidget {
|
||||
/// Whether the sign-in process is currently loading.
|
||||
final bool isLoading;
|
||||
|
||||
/// Callback for when the Send Code button is pressed.
|
||||
final VoidCallback? onSendCode;
|
||||
|
||||
/// Creates a [PhoneInputActions].
|
||||
const PhoneInputActions({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
this.onSendCode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: UiColors.separatorSecondary)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
isLoading
|
||||
? UiButton.secondary(
|
||||
onPressed: null,
|
||||
child: const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: UiButton.primary(
|
||||
text: t
|
||||
.staff_authentication
|
||||
.phone_verification_page
|
||||
.send_code_button,
|
||||
onPressed: onSendCode,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
const AuthTroubleLink(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget that displays the phone number input field with country code.
|
||||
///
|
||||
/// This widget handles its own [TextEditingController] to manage input.
|
||||
class PhoneInputFormField extends StatefulWidget {
|
||||
/// The initial value for the phone number.
|
||||
final String initialValue;
|
||||
|
||||
/// The error message to display, if any.
|
||||
final String error;
|
||||
|
||||
/// Callback for when the text field value changes.
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
/// Creates a [PhoneInputFormField].
|
||||
const PhoneInputFormField({
|
||||
super.key,
|
||||
this.initialValue = '',
|
||||
required this.error,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PhoneInputFormField> createState() => _PhoneInputFormFieldState();
|
||||
}
|
||||
|
||||
class _PhoneInputFormFieldState extends State<PhoneInputFormField> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.staff_authentication.phone_input.label,
|
||||
style: UiTypography.footnote1m.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('🇺🇸', style: UiTypography.headline2m),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text('+1', style: UiTypography.body1m),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText: t.staff_authentication.phone_input.hint,
|
||||
),
|
||||
onChanged: widget.onChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: UiConstants.space2),
|
||||
child: Text(widget.error, style: UiTypography.body2r.textError),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget that displays the title and subtitle for the Phone Input page.
|
||||
class PhoneInputHeader extends StatelessWidget {
|
||||
/// Creates a [PhoneInputHeader].
|
||||
const PhoneInputHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.staff_authentication.phone_input.title,
|
||||
style: UiTypography.headline1m,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
t.staff_authentication.phone_input.subtitle,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/common/section_title_subtitle.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget for setting up basic profile information (photo, name, bio).
|
||||
class ProfileSetupBasicInfo extends StatelessWidget {
|
||||
/// The user's full name.
|
||||
final String fullName;
|
||||
|
||||
/// The user's bio.
|
||||
final String bio;
|
||||
|
||||
/// Callback for when the full name changes.
|
||||
final ValueChanged<String> onFullNameChanged;
|
||||
|
||||
/// Callback for when the bio changes.
|
||||
final ValueChanged<String> onBioChanged;
|
||||
|
||||
/// Creates a [ProfileSetupBasicInfo] widget.
|
||||
const ProfileSetupBasicInfo({
|
||||
super.key,
|
||||
required this.fullName,
|
||||
required this.bio,
|
||||
required this.onFullNameChanged,
|
||||
required this.onBioChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
/// Builds the basic info step UI.
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionTitleSubtitle(
|
||||
title: t.staff_authentication.profile_setup_page.basic_info.title,
|
||||
subtitle:
|
||||
t.staff_authentication.profile_setup_page.basic_info.subtitle,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Photo Upload
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: UiColors.secondary,
|
||||
border: Border.all(
|
||||
color: UiColors.secondaryForeground.withAlpha(24),
|
||||
width: 4,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.user,
|
||||
size: 48,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: UiIconButton.secondary(icon: UiIcons.camera),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Full Name
|
||||
UiTextField(
|
||||
label: t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.basic_info
|
||||
.full_name_label,
|
||||
hintText: t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.basic_info
|
||||
.full_name_hint,
|
||||
onChanged: onFullNameChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Bio
|
||||
UiTextField(
|
||||
label: t.staff_authentication.profile_setup_page.basic_info.bio_label,
|
||||
hintText:
|
||||
t.staff_authentication.profile_setup_page.basic_info.bio_hint,
|
||||
maxLines: 3,
|
||||
onChanged: onBioChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/common/section_title_subtitle.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget for setting up skills and preferred industries.
|
||||
class ProfileSetupExperience extends StatelessWidget {
|
||||
/// The list of selected skills.
|
||||
final List<String> skills;
|
||||
|
||||
/// The list of selected industries.
|
||||
final List<String> industries;
|
||||
|
||||
/// Callback for when skills change.
|
||||
final ValueChanged<List<String>> onSkillsChanged;
|
||||
|
||||
/// Callback for when industries change.
|
||||
final ValueChanged<List<String>> onIndustriesChanged;
|
||||
|
||||
static const List<String> _allSkillKeys = [
|
||||
'food_service',
|
||||
'bartending',
|
||||
'warehouse',
|
||||
'retail',
|
||||
'events',
|
||||
'customer_service',
|
||||
'cleaning',
|
||||
'security',
|
||||
'driving',
|
||||
'cooking',
|
||||
];
|
||||
|
||||
static const List<String> _allIndustryKeys = [
|
||||
'hospitality',
|
||||
'food_service',
|
||||
'warehouse',
|
||||
'events',
|
||||
'retail',
|
||||
'healthcare',
|
||||
];
|
||||
|
||||
/// Creates a [ProfileSetupExperience] widget.
|
||||
const ProfileSetupExperience({
|
||||
super.key,
|
||||
required this.skills,
|
||||
required this.industries,
|
||||
required this.onSkillsChanged,
|
||||
required this.onIndustriesChanged,
|
||||
});
|
||||
|
||||
/// Toggles a skill.
|
||||
void _toggleSkill({required String skill}) {
|
||||
final updatedList = List<String>.from(skills);
|
||||
if (updatedList.contains(skill)) {
|
||||
updatedList.remove(skill);
|
||||
} else {
|
||||
updatedList.add(skill);
|
||||
}
|
||||
onSkillsChanged(updatedList);
|
||||
}
|
||||
|
||||
/// Toggles an industry.
|
||||
void _toggleIndustry({required String industry}) {
|
||||
final updatedList = List<String>.from(industries);
|
||||
if (updatedList.contains(industry)) {
|
||||
updatedList.remove(industry);
|
||||
} else {
|
||||
updatedList.add(industry);
|
||||
}
|
||||
onIndustriesChanged(updatedList);
|
||||
}
|
||||
|
||||
@override
|
||||
/// Builds the experience setup step UI.
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionTitleSubtitle(
|
||||
title: t.staff_authentication.profile_setup_page.experience.title,
|
||||
subtitle:
|
||||
t.staff_authentication.profile_setup_page.experience.subtitle,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Skills
|
||||
Text(
|
||||
t.staff_authentication.profile_setup_page.experience.skills_label,
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: _allSkillKeys.map((key) {
|
||||
final isSelected = skills.contains(key);
|
||||
// Dynamic translation access
|
||||
final label = _getSkillLabel(key);
|
||||
|
||||
return UiChip(
|
||||
label: label,
|
||||
isSelected: isSelected,
|
||||
onTap: () => _toggleSkill(skill: key),
|
||||
leadingIcon: isSelected ? UiIcons.check : null,
|
||||
variant: UiChipVariant.primary,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Industries
|
||||
Text(
|
||||
t.staff_authentication.profile_setup_page.experience.industries_label,
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: _allIndustryKeys.map((key) {
|
||||
final isSelected = industries.contains(key);
|
||||
final label = _getIndustryLabel(key);
|
||||
|
||||
return UiChip(
|
||||
label: label,
|
||||
isSelected: isSelected,
|
||||
onTap: () => _toggleIndustry(industry: key),
|
||||
leadingIcon: isSelected ? UiIcons.check : null,
|
||||
variant: isSelected
|
||||
? UiChipVariant.accent
|
||||
: UiChipVariant.primary,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getSkillLabel(String key) {
|
||||
switch (key) {
|
||||
case 'food_service':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.food_service;
|
||||
case 'bartending':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.bartending;
|
||||
case 'warehouse':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.warehouse;
|
||||
case 'retail':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.retail;
|
||||
case 'events':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.events;
|
||||
case 'customer_service':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.customer_service;
|
||||
case 'cleaning':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.cleaning;
|
||||
case 'security':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.security;
|
||||
case 'driving':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.driving;
|
||||
case 'cooking':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.cooking;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
String _getIndustryLabel(String key) {
|
||||
switch (key) {
|
||||
case 'hospitality':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.hospitality;
|
||||
case 'food_service':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.food_service;
|
||||
case 'warehouse':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.warehouse;
|
||||
case 'events':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.events;
|
||||
case 'retail':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.retail;
|
||||
case 'healthcare':
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.healthcare;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A header widget for the profile setup page showing back button and step count.
|
||||
class ProfileSetupHeader extends StatelessWidget {
|
||||
/// The current step index (0-based).
|
||||
final int currentStep;
|
||||
|
||||
/// The total number of steps.
|
||||
final int totalSteps;
|
||||
|
||||
/// Callback when the back button is tapped.
|
||||
final VoidCallback? onBackTap;
|
||||
|
||||
/// Creates a [ProfileSetupHeader].
|
||||
const ProfileSetupHeader({
|
||||
super.key,
|
||||
required this.currentStep,
|
||||
required this.totalSteps,
|
||||
this.onBackTap,
|
||||
});
|
||||
|
||||
@override
|
||||
/// Builds the header UI.
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
vertical: UiConstants.space4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (currentStep > 0 && onBackTap != null)
|
||||
GestureDetector(
|
||||
onTap: onBackTap,
|
||||
child: const Icon(
|
||||
UiIcons.chevronLeft,
|
||||
size: 20,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: UiConstants.space6),
|
||||
Text(
|
||||
t.staff_authentication.profile_setup_page.step_indicator(
|
||||
current: currentStep + 1,
|
||||
total: totalSteps,
|
||||
),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/common/section_title_subtitle.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
/// A widget for setting up preferred work locations and distance.
|
||||
class ProfileSetupLocation extends StatefulWidget {
|
||||
/// The list of preferred locations.
|
||||
final List<String> preferredLocations;
|
||||
|
||||
/// The maximum distance in miles.
|
||||
final double maxDistanceMiles;
|
||||
|
||||
/// Callback for when the preferred locations list changes.
|
||||
final ValueChanged<List<String>> onLocationsChanged;
|
||||
|
||||
/// Callback for when the max distance changes.
|
||||
final ValueChanged<double> onDistanceChanged;
|
||||
|
||||
/// Creates a [ProfileSetupLocation] widget.
|
||||
const ProfileSetupLocation({
|
||||
super.key,
|
||||
required this.preferredLocations,
|
||||
required this.maxDistanceMiles,
|
||||
required this.onLocationsChanged,
|
||||
required this.onDistanceChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProfileSetupLocation> createState() => _ProfileSetupLocationState();
|
||||
}
|
||||
|
||||
class _ProfileSetupLocationState extends State<ProfileSetupLocation> {
|
||||
final TextEditingController _locationController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_locationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Adds the current text from the controller as a location.
|
||||
void _addLocation() {
|
||||
final loc = _locationController.text.trim();
|
||||
if (loc.isNotEmpty && !widget.preferredLocations.contains(loc)) {
|
||||
final updatedList = List<String>.from(widget.preferredLocations)
|
||||
..add(loc);
|
||||
widget.onLocationsChanged(updatedList);
|
||||
_locationController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
/// Builds the location setup step UI.
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionTitleSubtitle(
|
||||
title: t.staff_authentication.profile_setup_page.location.title,
|
||||
subtitle: t.staff_authentication.profile_setup_page.location.subtitle,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Add Location input
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
spacing: UiConstants.space2,
|
||||
children: [
|
||||
Expanded(
|
||||
child: UiTextField(
|
||||
label: t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.location
|
||||
.add_location_label,
|
||||
controller: _locationController,
|
||||
hintText: t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.location
|
||||
.add_location_hint,
|
||||
onSubmitted: (_) => _addLocation(),
|
||||
),
|
||||
),
|
||||
UiButton.secondary(
|
||||
text:
|
||||
t.staff_authentication.profile_setup_page.location.add_button,
|
||||
onPressed: _addLocation,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
maximumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Location Badges
|
||||
if (widget.preferredLocations.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: widget.preferredLocations.map((loc) {
|
||||
return UiChip(
|
||||
label: loc,
|
||||
leadingIcon: UiIcons.mapPin,
|
||||
trailingIcon: UiIcons.close,
|
||||
onTrailingIconTap: () => _removeLocation(location: loc),
|
||||
variant: UiChipVariant.secondary,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
// Slider
|
||||
Text(
|
||||
t.staff_authentication.profile_setup_page.location.max_distance(
|
||||
distance: widget.maxDistanceMiles.round().toString(),
|
||||
),
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Slider(
|
||||
value: widget.maxDistanceMiles,
|
||||
min: 5,
|
||||
max: 50,
|
||||
onChanged: widget.onDistanceChanged,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.location
|
||||
.min_dist_label,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
Text(
|
||||
t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.location
|
||||
.max_dist_label,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes the specified [location] from the list.
|
||||
void _removeLocation({required String location}) {
|
||||
final updatedList = List<String>.from(widget.preferredLocations)
|
||||
..remove(location);
|
||||
widget.onLocationsChanged(updatedList);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user