initalizing the mobile apps

This commit is contained in:
Achintha Isuru
2026-01-21 15:42:51 -05:00
parent fbadd976cf
commit 4a67b2f541
578 changed files with 28462 additions and 2 deletions

View File

@@ -0,0 +1,33 @@
# Feature Manifest: Staff Authentication
## Overview
**Feature Name:** Staff Authentication & Onboarding
**Package Path:** `packages/features/staff/authentication`
## Responsibilities
* Handle user sign-up and log-in via Phone Auth.
* Verify OTP codes.
* Manage the Onboarding Wizard for new staff.
* Persist onboarding progress.
## Architecture
* **Domain**:
* `AuthRepositoryInterface`
* `SignInWithPhoneUseCase`
* `VerifyOtpUseCase`
* **Data**:
* `AuthRepositoryImpl` (uses `AuthRepositoryMock` from `krow_data_connect`)
* **Presentation**:
* `AuthBloc`: Manages auth state (phone, otp, user status).
* `OnboardingBloc`: Manages wizard steps.
* Pages: `GetStartedPage`, `PhoneVerificationPage`, `ProfileSetupPage`.
## Dependencies
* `krow_domain`: User entities.
* `krow_data_connect`: Auth mocks.
* `design_system`: UI components.
## Routes
* `/`: Get Started (Welcome)
* `/phone-verification`: OTP Entry
* `/profile-setup`: Onboarding Wizard

View File

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

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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.
}

View File

@@ -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,
}

View File

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

View File

@@ -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,
);
}
}

View File

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

View File

@@ -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];
}

View File

@@ -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,
);
}
}

View File

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

View File

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

View File

@@ -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,
];
}

View File

@@ -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');
}
}

View File

@@ -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),
],
),
),
],
),
),
);
}
}

View File

@@ -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,
),
),
),
);
},
),
);
},
),
);
}
}

View File

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

View File

@@ -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,
),
],
);
}
}

View File

@@ -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),
],
);
}
}

View File

@@ -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,
),
],
);
}
}

View File

@@ -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),
],
),
),
],
),
);
}
}

View File

@@ -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,
),
],
);
}
}

View File

@@ -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,
),
],
);
}
}

View File

@@ -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),
),
),
],
);
}
}

View File

@@ -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),
),
),
),
],
),
),
);
}
}

View File

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

View File

@@ -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,
),
],
),
),
],
);
}
}

View File

@@ -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),
],
);
}
}

View File

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

View File

@@ -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),
),
],
);
}
}

View File

@@ -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,
),
],
);
}
}

View File

@@ -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,
),
],
);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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,
),
],
),
);
}
}

View File

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

View File

@@ -0,0 +1,65 @@
library staff_authentication;
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:staff_authentication/src/data/repositories_impl/auth_repository_impl.dart';
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart';
import 'package:staff_authentication/src/domain/usecases/verify_otp_usecase.dart';
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart';
import 'package:staff_authentication/src/presentation/pages/get_started_page.dart';
import 'package:staff_authentication/src/presentation/pages/phone_verification_page.dart';
import 'package:staff_authentication/src/presentation/pages/profile_setup_page.dart';
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
export 'src/domain/ui_entities/auth_mode.dart';
export 'src/presentation/pages/get_started_page.dart';
export 'src/presentation/pages/phone_verification_page.dart';
export 'src/presentation/pages/profile_setup_page.dart';
export 'package:core_localization/core_localization.dart';
/// A [Module] for the staff authentication feature.
class StaffAuthenticationModule extends Module {
@override
List<Module> get imports => [DataConnectModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<AuthRepositoryInterface>(
() => AuthRepositoryImpl(mock: i.get<AuthRepositoryMock>()),
);
// UseCases
i.addLazySingleton(SignInWithPhoneUseCase.new);
i.addLazySingleton(VerifyOtpUseCase.new);
// BLoCs
i.addLazySingleton<AuthBloc>(
() => AuthBloc(
signInUseCase: i.get<SignInWithPhoneUseCase>(),
verifyOtpUseCase: i.get<VerifyOtpUseCase>(),
),
);
i.add<ProfileSetupBloc>(ProfileSetupBloc.new);
}
@override
void routes(r) {
r.child('/', child: (_) => const GetStartedPage());
r.child(
'/phone-verification',
child: (context) {
final Map<String, dynamic>? data = r.args.data;
final String? modeName = data?['mode'];
final AuthMode mode = AuthMode.values.firstWhere(
(e) => e.name == modeName,
orElse: () => AuthMode.login,
);
return PhoneVerificationPage(mode: mode);
},
);
r.child('/profile-setup', child: (_) => const ProfileSetupPage());
}
}

View File

@@ -0,0 +1,40 @@
name: staff_authentication
description: Staff Authentication and Onboarding 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
lucide_icons: ^0.257.0
# Architecture Packages
krow_domain:
path: ../../../../domain
krow_data_connect:
path: ../../../../data_connect
krow_core:
path: ../../../../core
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