Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new

This commit is contained in:
2026-03-18 12:51:23 +05:30
660 changed files with 18935 additions and 21383 deletions

View File

@@ -1,59 +1,98 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart' as domain;
import '../../utils/test_phone_numbers.dart';
import '../../domain/ui_entities/auth_mode.dart';
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
import 'package:staff_authentication/src/utils/test_phone_numbers.dart';
import '../../domain/repositories/auth_repository_interface.dart';
/// Implementation of [AuthRepositoryInterface].
/// V2 API implementation of [AuthRepositoryInterface].
///
/// Uses the Firebase Auth SDK for client-side phone verification,
/// then calls the V2 unified API to hydrate the session context.
/// All Data Connect dependencies have been removed.
class AuthRepositoryImpl implements AuthRepositoryInterface {
AuthRepositoryImpl() : _service = DataConnectService.instance;
/// Creates an [AuthRepositoryImpl].
///
/// Requires a [domain.BaseApiService] for V2 API calls.
AuthRepositoryImpl({required domain.BaseApiService apiService})
: _apiService = apiService;
final DataConnectService _service;
/// The V2 API service for backend calls.
final domain.BaseApiService _apiService;
/// Firebase Auth instance for client-side phone verification.
final FirebaseAuth _auth = FirebaseAuth.instance;
/// Completer for the pending phone verification request.
Completer<String?>? _pendingVerification;
@override
Stream<domain.User?> get currentUser =>
_service.auth.authStateChanges().map((User? firebaseUser) {
_auth.authStateChanges().map((User? firebaseUser) {
if (firebaseUser == null) {
return null;
}
return domain.User(
id: firebaseUser.uid,
email: firebaseUser.email ?? '',
email: firebaseUser.email,
displayName: firebaseUser.displayName,
phone: firebaseUser.phoneNumber,
role: 'staff',
status: domain.UserStatus.active,
);
});
/// Signs in with a phone number and returns a verification ID.
/// Initiates phone verification via the V2 API.
///
/// Calls `POST /auth/staff/phone/start` first. The server decides the
/// verification mode:
/// - `CLIENT_FIREBASE_SDK` — mobile must do Firebase phone auth client-side
/// - `IDENTITY_TOOLKIT_SMS` — server sent the SMS, returns `sessionInfo`
///
/// For mobile without recaptcha tokens, the server returns
/// `CLIENT_FIREBASE_SDK` and we fall back to the Firebase Auth SDK.
@override
Future<String?> signInWithPhone({required String phoneNumber}) async {
// Step 1: Try V2 to let the server decide the auth mode.
// Falls back to CLIENT_FIREBASE_SDK if the API call fails (e.g. server
// down, 500, or non-JSON response).
String mode = 'CLIENT_FIREBASE_SDK';
String? sessionInfo;
try {
final domain.ApiResponse startResponse = await _apiService.post(
AuthEndpoints.staffPhoneStart,
data: <String, dynamic>{
'phoneNumber': phoneNumber,
},
);
final Map<String, dynamic> startData =
startResponse.data as Map<String, dynamic>;
mode = startData['mode'] as String? ?? 'CLIENT_FIREBASE_SDK';
sessionInfo = startData['sessionInfo'] as String?;
} catch (_) {
// V2 start call failed — fall back to client-side Firebase SDK.
}
// Step 2: If server sent the SMS, return the sessionInfo for verify step.
if (mode == 'IDENTITY_TOOLKIT_SMS') {
return sessionInfo;
}
// Step 3: CLIENT_FIREBASE_SDK mode — do Firebase phone auth client-side.
final Completer<String?> completer = Completer<String?>();
_pendingVerification = completer;
await _service.auth.verifyPhoneNumber(
await _auth.verifyPhoneNumber(
phoneNumber: phoneNumber,
verificationCompleted: (PhoneAuthCredential credential) {
// Skip auto-verification for test numbers to allow manual code entry
if (TestPhoneNumbers.isTestNumber(phoneNumber)) {
return;
}
// For real numbers, we can support auto-verification if desired.
// But since this method returns a verificationId for manual OTP entry,
// we might not handle direct sign-in here unless the architecture changes.
// Currently, we just ignore it for the completer flow,
// or we could sign in directly if the credential is provided.
if (TestPhoneNumbers.isTestNumber(phoneNumber)) return;
},
verificationFailed: (FirebaseAuthException e) {
if (!completer.isCompleted) {
// Map Firebase network errors to NetworkException
if (e.code == 'network-request-failed' ||
e.message?.contains('Unable to resolve host') == true) {
completer.completeError(
@@ -94,35 +133,36 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
_pendingVerification = null;
}
/// Signs out the current user.
@override
Future<void> signOut() async {
return await _service.signOut();
}
/// Verifies an OTP code and returns the authenticated user.
/// Verifies the OTP and completes authentication via the V2 API.
///
/// 1. Signs in with the Firebase credential (client-side).
/// 2. Gets the Firebase ID token.
/// 3. Calls `POST /auth/staff/phone/verify` with the ID token and mode.
/// 4. Parses the V2 auth envelope and populates the session.
@override
Future<domain.User?> verifyOtp({
required String verificationId,
required String smsCode,
required AuthMode mode,
}) async {
// Step 1: Sign in with Firebase credential (client-side).
final PhoneAuthCredential credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
final UserCredential userCredential = await _service.run(() async {
try {
return await _service.auth.signInWithCredential(credential);
} on FirebaseAuthException catch (e) {
if (e.code == 'invalid-verification-code') {
throw const domain.InvalidCredentialsException(
technicalMessage: 'Invalid OTP code entered.',
);
}
rethrow;
final UserCredential userCredential;
try {
userCredential = await _auth.signInWithCredential(credential);
} on FirebaseAuthException catch (e) {
if (e.code == 'invalid-verification-code') {
throw const domain.InvalidCredentialsException(
technicalMessage: 'Invalid OTP code entered.',
);
}
}, requiresAuthentication: false);
rethrow;
}
final User? firebaseUser = userCredential.user;
if (firebaseUser == null) {
throw const domain.SignInFailedException(
@@ -131,115 +171,74 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
);
}
final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
await _service.run(
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
requiresAuthentication: false,
);
final GetUserByIdUser? user = response.data.user;
GetStaffByUserIdStaffs? staffRecord;
if (mode == AuthMode.signup) {
if (user == null) {
await _service.run(
() => _service.connector
.createUser(id: firebaseUser.uid, role: UserBaseRole.USER)
.userRole('STAFF')
.execute(),
requiresAuthentication: false,
);
} else {
// User exists in PostgreSQL. Check if they have a STAFF profile.
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
staffResponse = await _service.run(
() => _service.connector
.getStaffByUserId(userId: firebaseUser.uid)
.execute(),
requiresAuthentication: false,
);
if (staffResponse.data.staffs.isNotEmpty) {
// If profile exists, they should use Login mode.
await _service.signOut();
throw const domain.AccountExistsException(
technicalMessage:
'This user already has a staff profile. Please log in.',
);
}
// If they don't have a staff profile but they exist as BUSINESS,
// they are allowed to "Sign Up" for Staff.
// We update their userRole to 'BOTH'.
if (user.userRole == 'BUSINESS') {
await _service.run(
() => _service.connector
.updateUser(id: firebaseUser.uid)
.userRole('BOTH')
.execute(),
requiresAuthentication: false,
);
}
}
} else {
if (user == null) {
await _service.signOut();
throw const domain.UserNotFoundException(
technicalMessage: 'Authenticated user profile not found in database.',
);
}
// Allow STAFF or BOTH roles to log in to the Staff App
if (user.userRole != 'STAFF' && user.userRole != 'BOTH') {
await _service.signOut();
throw const domain.UnauthorizedAppException(
technicalMessage: 'User is not authorized for this app.',
);
}
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
staffResponse = await _service.run(
() => _service.connector
.getStaffByUserId(userId: firebaseUser.uid)
.execute(),
requiresAuthentication: false,
// Step 2: Get the Firebase ID token.
final String? idToken = await firebaseUser.getIdToken();
if (idToken == null) {
throw const domain.SignInFailedException(
technicalMessage: 'Failed to obtain Firebase ID token.',
);
if (staffResponse.data.staffs.isEmpty) {
await _service.signOut();
}
// Step 3: Call V2 verify endpoint with the Firebase ID token.
final String v2Mode = mode == AuthMode.signup ? 'sign-up' : 'sign-in';
final domain.ApiResponse response = await _apiService.post(
AuthEndpoints.staffPhoneVerify,
data: <String, dynamic>{
'idToken': idToken,
'mode': v2Mode,
},
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
// Step 4: Check for business logic errors from the V2 API.
final Map<String, dynamic>? staffData =
data['staff'] as Map<String, dynamic>?;
final Map<String, dynamic>? userData =
data['user'] as Map<String, dynamic>?;
// Handle mode-specific logic:
// - Sign-up: staff may be null (requiresProfileSetup=true)
// - Sign-in: staff must exist
if (mode == AuthMode.login) {
if (staffData == null) {
await _auth.signOut();
throw const domain.UserNotFoundException(
technicalMessage:
'Your account is not registered yet. Please register first.',
);
}
staffRecord = staffResponse.data.staffs.first;
}
//TO-DO: create(registration) user and staff account
//TO-DO: save user data locally
// Step 5: Populate StaffSessionStore from the V2 auth envelope.
if (staffData != null) {
final domain.StaffSession staffSession =
domain.StaffSession.fromJson(data);
StaffSessionStore.instance.setSession(staffSession);
}
// Build the domain user from the V2 response.
final domain.User domainUser = domain.User(
id: firebaseUser.uid,
email: user?.email ?? '',
phone: user?.phone,
role: user?.role.stringValue ?? 'USER',
);
final domain.Staff? domainStaff = staffRecord == null
? null
: domain.Staff(
id: staffRecord.id,
authProviderId: staffRecord.userId,
name: staffRecord.fullName,
email: staffRecord.email ?? '',
phone: staffRecord.phone,
status: domain.StaffStatus.completedProfile,
address: staffRecord.addres,
avatar: staffRecord.photoUrl,
);
StaffSessionStore.instance.setSession(
StaffSession(
staff: domainStaff,
ownerId: staffRecord?.ownerId,
),
id: userData?['id'] as String? ?? firebaseUser.uid,
email: userData?['email'] as String?,
displayName: userData?['displayName'] as String?,
phone: userData?['phone'] as String? ?? firebaseUser.phoneNumber,
status: domain.UserStatus.active,
);
return domainUser;
}
/// Signs out via the V2 API and locally.
@override
Future<void> signOut() async {
try {
await _apiService.post(AuthEndpoints.staffSignOut);
} catch (_) {
// Sign-out should not fail even if the API call fails.
// The local sign-out below will clear the session regardless.
}
await _auth.signOut();
StaffSessionStore.instance.clear();
}
}

View File

@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:krow_core/core.dart';
import '../../domain/repositories/place_repository.dart';
import 'package:staff_authentication/src/domain/repositories/place_repository.dart';
class PlaceRepositoryImpl implements PlaceRepository {

View File

@@ -1,63 +1,67 @@
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:firebase_auth/firebase_auth.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:firebase_auth/firebase_auth.dart' as auth;
import '../../domain/repositories/profile_setup_repository.dart';
import 'package:staff_authentication/src/domain/repositories/profile_setup_repository.dart';
/// V2 API implementation of [ProfileSetupRepository].
///
/// Submits the staff profile setup data to the V2 unified API
/// endpoint `POST /staff/profile/setup`.
class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
/// Creates a [ProfileSetupRepositoryImpl].
///
/// Requires a [BaseApiService] for V2 API calls.
ProfileSetupRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
ProfileSetupRepositoryImpl() : _service = DataConnectService.instance;
final DataConnectService _service;
/// The V2 API service for backend calls.
final BaseApiService _apiService;
@override
Future<void> submitProfile({
required String fullName,
required String phoneNumber,
String? bio,
required List<String> preferredLocations,
required double maxDistanceMiles,
required List<String> industries,
required List<String> skills,
}) async {
return _service.run(() async {
final auth.User? firebaseUser = _service.auth.currentUser;
if (firebaseUser == null) {
throw const NotAuthenticatedException(
technicalMessage: 'User not authenticated.',
);
}
// Convert location label strings to the object shape the V2 API expects.
// The backend zod schema requires: { label, city?, state?, ... }.
final List<Map<String, String>> locationObjects = preferredLocations
.map((String label) => <String, String>{'label': label})
.toList();
final StaffSession? session = StaffSessionStore.instance.session;
final String email = session?.staff?.email ?? '';
final String? phone = firebaseUser.phoneNumber;
// Resolve the phone number: prefer the explicit parameter, but fall back
// to the Firebase Auth current user's phone if the caller passed empty.
final String resolvedPhone = phoneNumber.isNotEmpty
? phoneNumber
: (FirebaseAuth.instance.currentUser?.phoneNumber ?? '');
final fdc.OperationResult<CreateStaffData, CreateStaffVariables> result =
await _service.connector
.createStaff(userId: firebaseUser.uid, fullName: fullName)
.bio(bio)
.preferredLocations(preferredLocations)
.maxDistanceMiles(maxDistanceMiles.toInt())
.industries(industries)
.skills(skills)
.email(email.isEmpty ? null : email)
.phone(phone)
.execute();
final ApiResponse response = await _apiService.post(
StaffEndpoints.profileSetup,
data: <String, dynamic>{
'fullName': fullName,
'phoneNumber': resolvedPhone,
if (bio != null && bio.isNotEmpty) 'bio': bio,
'preferredLocations': locationObjects,
'maxDistanceMiles': maxDistanceMiles.toInt(),
'industries': industries,
'skills': skills,
},
);
final String staffId = result.data.staff_insert.id;
final Staff staff = Staff(
id: staffId,
authProviderId: firebaseUser.uid,
name: fullName,
email: email,
phone: phone,
status: StaffStatus.completedProfile,
// Check for API-level errors.
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
if (data['code'] != null &&
data['code'].toString() != '200' &&
data['code'].toString() != '201') {
throw SignInFailedException(
technicalMessage:
data['message']?.toString() ?? 'Profile setup failed.',
);
if (session != null) {
StaffSessionStore.instance.setSession(
StaffSession(staff: staff, ownerId: session.ownerId),
);
}
});
}
}
}

View File

@@ -1,5 +1,5 @@
import 'package:krow_core/core.dart';
import '../ui_entities/auth_mode.dart';
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
/// Represents the arguments required for the [VerifyOtpUseCase].
///

View File

@@ -1,24 +1,34 @@
import 'package:krow_domain/krow_domain.dart';
import '../ui_entities/auth_mode.dart';
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
/// Interface for authentication repository.
///
/// Defines the contract for staff phone-based authentication,
/// OTP verification, and sign-out operations.
abstract interface class AuthRepositoryInterface {
/// Stream of the current Firebase Auth user mapped to a domain [User].
Stream<User?> get currentUser;
/// Signs in with a phone number and returns a verification ID.
/// Initiates phone verification and returns a verification ID.
///
/// Uses the Firebase Auth SDK client-side to send an SMS code.
Future<String?> signInWithPhone({required String phoneNumber});
/// Cancels any pending phone verification request (if possible).
void cancelPendingPhoneVerification();
/// Verifies the OTP code and returns the authenticated user.
/// Verifies the OTP code and completes authentication via the V2 API.
///
/// After Firebase credential sign-in, calls the V2 verify endpoint
/// to hydrate the session context. Returns the authenticated [User]
/// or `null` if verification fails.
Future<User?> verifyOtp({
required String verificationId,
required String smsCode,
required AuthMode mode,
});
/// Signs out the current user.
/// Signs out the current user via the V2 API and locally.
Future<void> signOut();
}

View File

@@ -1,7 +1,9 @@
/// Interface for the staff profile setup repository.
abstract class ProfileSetupRepository {
/// Submits the staff profile setup data to the backend.
Future<void> submitProfile({
required String fullName,
required String phoneNumber,
String? bio,
required List<String> preferredLocations,
required double maxDistanceMiles,

View File

@@ -1,10 +1,16 @@
import '../repositories/place_repository.dart';
import 'package:staff_authentication/src/domain/repositories/place_repository.dart';
/// Use case for searching cities via the Places API.
///
/// Delegates to [PlaceRepository] for autocomplete results.
class SearchCitiesUseCase {
/// Creates a [SearchCitiesUseCase].
SearchCitiesUseCase(this._repository);
/// The repository for place search operations.
final PlaceRepository _repository;
/// Searches for cities matching the given [query].
Future<List<String>> call(String query) {
return _repository.searchCities(query);
}

View File

@@ -1,17 +1,16 @@
import 'package:krow_core/core.dart';
import '../arguments/sign_in_with_phone_arguments.dart';
import '../repositories/auth_repository_interface.dart';
import 'package:staff_authentication/src/domain/arguments/sign_in_with_phone_arguments.dart';
import 'package:staff_authentication/src/domain/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].
/// Delegates the sign-in logic to the [AuthRepositoryInterface].
class SignInWithPhoneUseCase
implements UseCase<SignInWithPhoneArguments, String?> {
/// Creates a [SignInWithPhoneUseCase].
///
/// Requires an [AuthRepositoryInterface] to interact with the authentication data source.
SignInWithPhoneUseCase(this._repository);
/// The repository for authentication operations.
final AuthRepositoryInterface _repository;
@override
@@ -19,6 +18,7 @@ class SignInWithPhoneUseCase
return _repository.signInWithPhone(phoneNumber: arguments.phoneNumber);
}
/// Cancels any pending phone verification request.
void cancelPending() {
_repository.cancelPendingPhoneVerification();
}

View File

@@ -1,12 +1,19 @@
import '../repositories/profile_setup_repository.dart';
import 'package:staff_authentication/src/domain/repositories/profile_setup_repository.dart';
/// Use case for submitting the staff profile setup.
///
/// Delegates to [ProfileSetupRepository] to persist the profile data.
class SubmitProfileSetup {
/// Creates a [SubmitProfileSetup].
SubmitProfileSetup(this.repository);
/// The repository for profile setup operations.
final ProfileSetupRepository repository;
/// Submits the profile setup with the given data.
Future<void> call({
required String fullName,
required String phoneNumber,
String? bio,
required List<String> preferredLocations,
required double maxDistanceMiles,
@@ -15,6 +22,7 @@ class SubmitProfileSetup {
}) {
return repository.submitProfile(
fullName: fullName,
phoneNumber: phoneNumber,
bio: bio,
preferredLocations: preferredLocations,
maxDistanceMiles: maxDistanceMiles,

View File

@@ -1,17 +1,16 @@
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';
import 'package:staff_authentication/src/domain/arguments/verify_otp_arguments.dart';
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
/// Use case for verifying an OTP code.
///
/// This use case delegates the OTP verification logic to the [AuthRepositoryInterface].
/// Delegates the OTP verification logic to the [AuthRepositoryInterface].
class VerifyOtpUseCase implements UseCase<VerifyOtpArguments, User?> {
/// Creates a [VerifyOtpUseCase].
///
/// Requires an [AuthRepositoryInterface] to interact with the authentication data source.
VerifyOtpUseCase(this._repository);
/// The repository for authentication operations.
final AuthRepositoryInterface _repository;
@override

View File

@@ -1,27 +1,29 @@
import 'dart:async';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:bloc/bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.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';
import 'package:staff_authentication/src/domain/arguments/sign_in_with_phone_arguments.dart';
import 'package:staff_authentication/src/domain/arguments/verify_otp_arguments.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_event.dart';
import 'package:staff_authentication/src/presentation/blocs/auth_state.dart';
/// BLoC responsible for handling authentication logic.
///
/// Coordinates phone verification and OTP submission via use cases.
class AuthBloc extends Bloc<AuthEvent, AuthState>
with BlocErrorHandler<AuthState>
implements Disposable {
/// Creates an [AuthBloc].
AuthBloc({
required SignInWithPhoneUseCase signInUseCase,
required VerifyOtpUseCase verifyOtpUseCase,
}) : _signInUseCase = signInUseCase,
_verifyOtpUseCase = verifyOtpUseCase,
super(const AuthState()) {
}) : _signInUseCase = signInUseCase,
_verifyOtpUseCase = verifyOtpUseCase,
super(const AuthState()) {
on<AuthSignInRequested>(_onSignInRequested);
on<AuthOtpSubmitted>(_onOtpSubmitted);
on<AuthErrorCleared>(_onErrorCleared);
@@ -30,15 +32,26 @@ class AuthBloc extends Bloc<AuthEvent, AuthState>
on<AuthResetRequested>(_onResetRequested);
on<AuthCooldownTicked>(_onCooldownTicked);
}
/// The use case for signing in with a phone number.
final SignInWithPhoneUseCase _signInUseCase;
/// The use case for verifying an OTP.
final VerifyOtpUseCase _verifyOtpUseCase;
/// Token to track the latest request and ignore stale completions.
int _requestToken = 0;
/// Timestamp of the last code request for cooldown enforcement.
DateTime? _lastCodeRequestAt;
/// When the cooldown expires.
DateTime? _cooldownUntil;
/// Duration users must wait between code requests.
static const Duration _resendCooldown = Duration(seconds: 31);
/// Timer for ticking down the cooldown.
Timer? _cooldownTimer;
/// Clears any authentication error from the state.
@@ -138,6 +151,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState>
);
}
/// Handles cooldown tick events.
void _onCooldownTicked(
AuthCooldownTicked event,
Emitter<AuthState> emit,
@@ -165,22 +179,27 @@ class AuthBloc extends Bloc<AuthEvent, AuthState>
);
}
/// Starts the cooldown timer with the given remaining seconds.
void _startCooldown(int secondsRemaining) {
_cancelCooldownTimer();
int remaining = secondsRemaining;
add(AuthCooldownTicked(remaining));
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (Timer timer) {
remaining -= 1;
if (remaining <= 0) {
timer.cancel();
_cooldownTimer = null;
add(const AuthCooldownTicked(0));
return;
}
add(AuthCooldownTicked(remaining));
});
_cooldownTimer = Timer.periodic(
const Duration(seconds: 1),
(Timer timer) {
remaining -= 1;
if (remaining <= 0) {
timer.cancel();
_cooldownTimer = null;
add(const AuthCooldownTicked(0));
return;
}
add(AuthCooldownTicked(remaining));
},
);
}
/// Cancels the cooldown timer if active.
void _cancelCooldownTimer() {
_cooldownTimer?.cancel();
_cooldownTimer = null;
@@ -218,4 +237,3 @@ class AuthBloc extends Bloc<AuthEvent, AuthState>
close();
}
}

View File

@@ -1,24 +1,28 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import '../../../domain/usecases/submit_profile_setup_usecase.dart';
import 'package:staff_authentication/src/domain/usecases/submit_profile_setup_usecase.dart';
import 'package:staff_authentication/src/domain/usecases/search_cities_usecase.dart';
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_event.dart';
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_state.dart';
import '../../../domain/usecases/search_cities_usecase.dart';
import 'profile_setup_event.dart';
import 'profile_setup_state.dart';
export 'profile_setup_event.dart';
export 'profile_setup_state.dart';
export 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_event.dart';
export 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_state.dart';
/// BLoC responsible for managing the profile setup state and logic.
class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
with BlocErrorHandler<ProfileSetupState> {
/// Creates a [ProfileSetupBloc].
///
/// [phoneNumber] is the authenticated user's phone from the sign-up flow,
/// required by the V2 profile-setup endpoint.
ProfileSetupBloc({
required SubmitProfileSetup submitProfileSetup,
required SearchCitiesUseCase searchCities,
}) : _submitProfileSetup = submitProfileSetup,
_searchCities = searchCities,
super(const ProfileSetupState()) {
required String phoneNumber,
}) : _submitProfileSetup = submitProfileSetup,
_searchCities = searchCities,
_phoneNumber = phoneNumber,
super(const ProfileSetupState()) {
on<ProfileSetupFullNameChanged>(_onFullNameChanged);
on<ProfileSetupBioChanged>(_onBioChanged);
on<ProfileSetupLocationsChanged>(_onLocationsChanged);
@@ -30,9 +34,15 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
on<ProfileSetupClearLocationSuggestions>(_onClearLocationSuggestions);
}
/// The use case for submitting the profile setup.
final SubmitProfileSetup _submitProfileSetup;
/// The use case for searching cities.
final SearchCitiesUseCase _searchCities;
/// The user's phone number from the sign-up flow.
final String _phoneNumber;
/// Handles the [ProfileSetupFullNameChanged] event.
void _onFullNameChanged(
ProfileSetupFullNameChanged event,
@@ -93,6 +103,7 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
action: () async {
await _submitProfileSetup(
fullName: state.fullName,
phoneNumber: _phoneNumber,
bio: state.bio.isEmpty ? null : state.bio,
preferredLocations: state.preferredLocations,
maxDistanceMiles: state.maxDistanceMiles,
@@ -109,6 +120,7 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
);
}
/// Handles location query changes for autocomplete search.
Future<void> _onLocationQueryChanged(
ProfileSetupLocationQueryChanged event,
Emitter<ProfileSetupState> emit,
@@ -118,17 +130,16 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
return;
}
// For search, we might want to handle errors silently or distinctively
// Using simple try-catch here as it's a search-as-you-type feature where error dialogs are intrusive
try {
final List<String> results = await _searchCities(event.query);
emit(state.copyWith(locationSuggestions: results));
} catch (e) {
// Quietly fail or clear
// Quietly fail for search-as-you-type.
emit(state.copyWith(locationSuggestions: <String>[]));
}
}
/// Clears the location suggestions list.
void _onClearLocationSuggestions(
ProfileSetupClearLocationSuggestions event,
Emitter<ProfileSetupState> emit,
@@ -136,4 +147,3 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
emit(state.copyWith(locationSuggestions: <String>[]));
}
}

View File

@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
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';
import 'package:staff_authentication/src/presentation/widgets/get_started_page/get_started_actions.dart';
import 'package:staff_authentication/src/presentation/widgets/get_started_page/get_started_background.dart';
import 'package:staff_authentication/src/presentation/widgets/get_started_page/get_started_header.dart';
/// The entry point page for staff authentication.
///

View File

@@ -9,8 +9,8 @@ import 'package:staff_authentication/src/presentation/blocs/auth_state.dart';
import 'package:staff_authentication/staff_authentication.dart';
import 'package:krow_core/core.dart';
import '../widgets/phone_verification_page/otp_verification.dart';
import '../widgets/phone_verification_page/phone_input.dart';
import 'package:staff_authentication/src/presentation/widgets/phone_verification_page/otp_verification.dart';
import 'package:staff_authentication/src/presentation/widgets/phone_verification_page/phone_input.dart';
/// A combined page for phone number entry and OTP verification.
///
@@ -105,6 +105,8 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
if (state.status == AuthStatus.authenticated) {
if (state.mode == AuthMode.signup) {
Modular.to.toProfileSetup();
} else {
Modular.to.toStaffHome();
}
} else if (state.status == AuthStatus.error &&
state.mode == AuthMode.signup) {
@@ -155,7 +157,7 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
BlocProvider.of<AuthBloc>(
context,
).add(AuthResetRequested(mode: widget.mode));
Modular.to.popSafe();;
Modular.to.popSafe();
},
),
body: SafeArea(

View File

@@ -6,11 +6,11 @@ import 'package:flutter_modular/flutter_modular.dart'
hide ModularWatchExtension;
import 'package:krow_core/core.dart';
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_experience.dart';
import '../widgets/profile_setup_page/profile_setup_header.dart';
import '../widgets/profile_setup_page/profile_setup_location.dart';
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart';
import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_basic_info.dart';
import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart';
import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_header.dart';
import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_location.dart';
/// Page for setting up the user profile after authentication.
class ProfileSetupPage extends StatefulWidget {

View File

@@ -5,8 +5,9 @@ import 'package:core_localization/core_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinput/pinput.dart';
import 'package:smart_auth/smart_auth.dart';
import '../../../blocs/auth_event.dart';
import '../../../blocs/auth_bloc.dart';
import 'package:staff_authentication/src/presentation/blocs/auth_event.dart';
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
/// A widget that displays a 6-digit OTP input field.
///

View File

@@ -2,7 +2,7 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../common/auth_trouble_link.dart';
import 'package:staff_authentication/src/presentation/widgets/common/auth_trouble_link.dart';
/// A widget that displays the primary action button and trouble link for OTP verification.
class OtpVerificationActions extends StatelessWidget {

View File

@@ -1,7 +1,6 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_authentication/src/presentation/widgets/common/section_title_subtitle.dart';
/// A widget for setting up skills and preferred industries.
@@ -27,6 +26,36 @@ class ProfileSetupExperience extends StatelessWidget {
/// Callback for when industries change.
final ValueChanged<List<String>> onIndustriesChanged;
/// Available skill options with their API values and labels.
static const List<String> _skillValues = <String>[
'food_service',
'bartending',
'event_setup',
'hospitality',
'warehouse',
'customer_service',
'cleaning',
'security',
'retail',
'cooking',
'cashier',
'server',
'barista',
'host_hostess',
'busser',
'driving',
];
/// Available industry options with their API values.
static const List<String> _industryValues = <String>[
'hospitality',
'food_service',
'warehouse',
'events',
'retail',
'healthcare',
];
/// Toggles a skill.
void _toggleSkill({required String skill}) {
final List<String> updatedList = List<String>.from(skills);
@@ -71,15 +100,14 @@ class ProfileSetupExperience extends StatelessWidget {
Wrap(
spacing: UiConstants.space2,
runSpacing: UiConstants.space2,
children: ExperienceSkill.values.map((ExperienceSkill skill) {
final bool isSelected = skills.contains(skill.value);
// Dynamic translation access
children: _skillValues.map((String skill) {
final bool isSelected = skills.contains(skill);
final String label = _getSkillLabel(skill);
return UiChip(
label: label,
isSelected: isSelected,
onTap: () => _toggleSkill(skill: skill.value),
onTap: () => _toggleSkill(skill: skill),
leadingIcon: isSelected ? UiIcons.check : null,
variant: UiChipVariant.primary,
);
@@ -97,14 +125,14 @@ class ProfileSetupExperience extends StatelessWidget {
Wrap(
spacing: UiConstants.space2,
runSpacing: UiConstants.space2,
children: Industry.values.map((Industry industry) {
final bool isSelected = industries.contains(industry.value);
children: _industryValues.map((String industry) {
final bool isSelected = industries.contains(industry);
final String label = _getIndustryLabel(industry);
return UiChip(
label: label,
isSelected: isSelected,
onTap: () => _toggleIndustry(industry: industry.value),
onTap: () => _toggleIndustry(industry: industry),
leadingIcon: isSelected ? UiIcons.check : null,
variant: isSelected
? UiChipVariant.accent
@@ -116,131 +144,71 @@ class ProfileSetupExperience extends StatelessWidget {
);
}
String _getSkillLabel(ExperienceSkill skill) {
String _getSkillLabel(String skill) {
final TranslationsStaffAuthenticationProfileSetupPageExperienceSkillsEn
skillsI18n = t
.staff_authentication
.profile_setup_page
.experience
.skills;
switch (skill) {
case ExperienceSkill.foodService:
return t
.staff_authentication
.profile_setup_page
.experience
.skills
.food_service;
case ExperienceSkill.bartending:
return t
.staff_authentication
.profile_setup_page
.experience
.skills
.bartending;
case ExperienceSkill.warehouse:
return t
.staff_authentication
.profile_setup_page
.experience
.skills
.warehouse;
case ExperienceSkill.retail:
return t
.staff_authentication
.profile_setup_page
.experience
.skills
.retail;
// Note: 'events' was removed from enum in favor of 'event_setup' or industry.
// Using 'events' translation for eventSetup if available or fallback.
case ExperienceSkill.eventSetup:
return t
.staff_authentication
.profile_setup_page
.experience
.skills
.events;
case ExperienceSkill.customerService:
return t
.staff_authentication
.profile_setup_page
.experience
.skills
.customer_service;
case ExperienceSkill.cleaning:
return t
.staff_authentication
.profile_setup_page
.experience
.skills
.cleaning;
case ExperienceSkill.security:
return t
.staff_authentication
.profile_setup_page
.experience
.skills
.security;
case ExperienceSkill.driving:
return t
.staff_authentication
.profile_setup_page
.experience
.skills
.driving;
case ExperienceSkill.cooking:
return t
.staff_authentication
.profile_setup_page
.experience
.skills
.cooking;
case 'food_service':
return skillsI18n.food_service;
case 'bartending':
return skillsI18n.bartending;
case 'warehouse':
return skillsI18n.warehouse;
case 'retail':
return skillsI18n.retail;
case 'event_setup':
return skillsI18n.events;
case 'customer_service':
return skillsI18n.customer_service;
case 'cleaning':
return skillsI18n.cleaning;
case 'security':
return skillsI18n.security;
case 'driving':
return skillsI18n.driving;
case 'cooking':
return skillsI18n.cooking;
case 'cashier':
return skillsI18n.cashier;
case 'server':
return skillsI18n.server;
case 'barista':
return skillsI18n.barista;
case 'host_hostess':
return skillsI18n.host_hostess;
case 'busser':
return skillsI18n.busser;
default:
return skill.value;
return skill;
}
}
String _getIndustryLabel(Industry industry) {
String _getIndustryLabel(String industry) {
final TranslationsStaffAuthenticationProfileSetupPageExperienceIndustriesEn
industriesI18n = t
.staff_authentication
.profile_setup_page
.experience
.industries;
switch (industry) {
case Industry.hospitality:
return t
.staff_authentication
.profile_setup_page
.experience
.industries
.hospitality;
case Industry.foodService:
return t
.staff_authentication
.profile_setup_page
.experience
.industries
.food_service;
case Industry.warehouse:
return t
.staff_authentication
.profile_setup_page
.experience
.industries
.warehouse;
case Industry.events:
return t
.staff_authentication
.profile_setup_page
.experience
.industries
.events;
case Industry.retail:
return t
.staff_authentication
.profile_setup_page
.experience
.industries
.retail;
case Industry.healthcare:
return t
.staff_authentication
.profile_setup_page
.experience
.industries
.healthcare;
case 'hospitality':
return industriesI18n.hospitality;
case 'food_service':
return industriesI18n.food_service;
case 'warehouse':
return industriesI18n.warehouse;
case 'events':
return industriesI18n.events;
case 'retail':
return industriesI18n.retail;
case 'healthcare':
return industriesI18n.healthcare;
default:
return industry.value;
return industry;
}
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.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';
@@ -20,17 +20,24 @@ import 'package:staff_authentication/src/presentation/pages/phone_verification_p
import 'package:staff_authentication/src/presentation/pages/profile_setup_page.dart';
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
/// A [Module] for the staff authentication feature.
/// A [Module] for the staff authentication feature.
///
/// Provides repositories, use cases, and BLoCs for phone-based
/// authentication and profile setup. Uses V2 API via [BaseApiService].
class StaffAuthenticationModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<ProfileSetupRepository>(ProfileSetupRepositoryImpl.new);
i.addLazySingleton<AuthRepositoryInterface>(
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
);
i.addLazySingleton<ProfileSetupRepository>(
() => ProfileSetupRepositoryImpl(apiService: i.get<BaseApiService>()),
);
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
// UseCases
i.addLazySingleton(SignInWithPhoneUseCase.new);
@@ -49,11 +56,11 @@ class StaffAuthenticationModule extends Module {
() => ProfileSetupBloc(
submitProfileSetup: i.get<SubmitProfileSetup>(),
searchCities: i.get<SearchCitiesUseCase>(),
phoneNumber: i.get<AuthBloc>().state.phoneNumber,
),
);
}
@override
void routes(RouteManager r) {
r.child(StaffPaths.root, child: (_) => const IntroPage());

View File

@@ -14,18 +14,14 @@ dependencies:
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
firebase_core: ^4.2.1
firebase_auth: ^6.1.2
firebase_data_connect: ^0.2.2+1
http: ^1.2.0
pinput: ^5.0.0
smart_auth: ^1.1.0
# Architecture Packages
krow_domain:
path: ../../../domain
krow_data_connect:
path: ../../../data_connect
krow_core:
path: ../../../core
design_system: