This commit is contained in:
mohamadmahdi jebeli 2025-08-13 14:51:38 +03:30
parent e43f9149a0
commit d994b45735
38 changed files with 1458 additions and 52 deletions

113
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,113 @@
# LBA App - Clean Architecture
## 📁 معماری پروژه
این پروژه بر اساس **Clean Architecture** و **BLoC Pattern** پیاده‌سازی شده است.
```
lib/
├── core/ # کتابخانه‌های مشترک
│ ├── error/
│ │ └── failures.dart # تعریف خطاها
│ └── usecases/
│ └── usecase.dart # Base class برای Use Cases
├── features/ # فیچرهای اپلیکیشن
│ └── auth/ # فیچر احراز هویت
│ ├── data/ # Data Layer
│ │ ├── datasources/
│ │ │ └── auth_remote_data_source.dart
│ │ ├── models/
│ │ │ └── otp_response_model.dart
│ │ └── repositories/
│ │ └── auth_repository_impl.dart
│ ├── domain/ # Domain Layer (Business Logic)
│ │ ├── entities/
│ │ │ └── otp_response.dart
│ │ ├── repositories/
│ │ │ └── auth_repository.dart
│ │ └── usecases/
│ │ ├── send_otp.dart
│ │ └── verify_otp.dart
│ └── presentation/ # Presentation Layer (UI)
│ ├── bloc/
│ │ ├── auth_bloc.dart
│ │ ├── auth_event.dart
│ │ └── auth_state.dart
│ ├── pages/
│ │ ├── onboarding_page.dart
│ │ ├── login_page.dart
│ │ └── otp_verification_page.dart
│ └── widgets/ # ویجت‌های مخصوص این فیچر
├── injection_container.dart # Dependency Injection
└── main.dart # Entry Point
```
## 🏗️ لایه‌های معماری
### 1. **Domain Layer (Business Logic)**
- **Entities**: اشیاء اصلی کسب‌وکار
- **Use Cases**: منطق کسب‌وکار
- **Repository Interfaces**: تعریف قراردادها
### 2. **Data Layer**
- **Models**: تبدیل داده‌ها
- **Data Sources**: منابع داده (API, Local DB)
- **Repository Implementation**: پیاده‌سازی Repository ها
### 3. **Presentation Layer (UI)**
- **BLoC**: مدیریت State
- **Pages**: صفحات اپلیکیشن
- **Widgets**: کامپوننت‌های UI
## 🔧 Dependency Injection
از یک Service Locator ساده استفاده شده:
```dart
// در main.dart
sl.init();
// استفاده در UI
BlocProvider(
create: (context) => sl.authBloc,
child: MyApp(),
)
```
## 🚀 وضعیت فعلی
### ✅ پیاده‌سازی شده:
- معماری Clean Architecture
- BLoC Pattern
- Mock Data برای تست
- Auth Feature (SendOTP, VerifyOTP)
### 🔄 آماده برای:
- اضافه کردن API واقعی
- اضافه کردن فیچرهای جدید
- تست‌نویسی
- بهبود UI/UX
## 📱 استفاده
### اجرای اپ:
```bash
flutter run
```
### اضافه کردن فیچر جدید:
1. پوشه جدید در `features/`
2. پیاده‌سازی لایه‌های `domain`, `data`, `presentation`
3. اضافه کردن به `injection_container.dart`
## 🎯 مزایای این معماری:
1. **مقیاس‌پذیری**: آسان برای اضافه کردن فیچرهای جدید
2. **تست‌پذیری**: جداسازی لایه‌ها
3. **نگهداری**: کد منظم و قابل فهم
4. **انعطاف‌پذیری**: تغییر آسان منابع داده
5. **استقلال**: لایه‌ها مستقل از یکدیگر
## 🔗 وابستگی‌ها
فعلاً تنها از پکیج‌های اصلی Flutter استفاده شده و نیاز به پکیج خارجی نیست.

View File

@ -7,6 +7,9 @@
# The following line activates a set of recommended lints for Flutter apps, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
analyzer:
errors:
file_names: ignore
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:

View File

@ -0,0 +1,11 @@
abstract class Failure {
const Failure();
}
class ServerFailure extends Failure {}
class CacheFailure extends Failure {}
class NetworkFailure extends Failure {}
class InvalidInputFailure extends Failure {}

View File

@ -0,0 +1,20 @@
import '../error/failures.dart';
class Result<T> {
final T? data;
final Failure? failure;
const Result.success(this.data) : failure = null;
const Result.failure(this.failure) : data = null;
bool get isSuccess => failure == null;
bool get isFailure => failure != null;
}
abstract class UseCase<Type, Params> {
Future<Result<Type>> call(Params params);
}
class NoParams {
const NoParams();
}

View File

@ -0,0 +1,34 @@
import '../models/otp_response_model.dart';
abstract class AuthRemoteDataSource {
Future<OtpResponseModel> sendOTP(String phoneNumber);
Future<bool> verifyOTP(String otpCode, String phoneNumber);
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
@override
Future<OtpResponseModel> sendOTP(String phoneNumber) async {
await Future.delayed(const Duration(seconds: 1));
return OtpResponseModel(
timeStamp: DateTime.now().millisecondsSinceEpoch.toString(),
timeDue: DateTime.now().add(const Duration(minutes: 2)).millisecondsSinceEpoch.toString(),
phoneNumber: phoneNumber,
);
}
@override
Future<bool> verifyOTP(String otpCode, String phoneNumber) async {
await Future.delayed(const Duration(seconds: 1));
if (otpCode.length == 5) {
return true;
}
if (otpCode == "1234" || otpCode == "0000" || otpCode == "12345") {
return true;
}
return false;
}
}

View File

@ -0,0 +1,25 @@
import '../../domain/entities/otp_response.dart';
class OtpResponseModel extends OtpResponse {
const OtpResponseModel({
required super.timeStamp,
required super.timeDue,
required super.phoneNumber,
});
factory OtpResponseModel.fromJson(Map<String, dynamic> json) {
return OtpResponseModel(
timeStamp: json['timestamp']?.toString() ?? '',
timeDue: json['due']?.toString() ?? '',
phoneNumber: json['phoneNumber']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'timestamp': timeStamp,
'due': timeDue,
'phoneNumber': phoneNumber,
};
}
}

View File

@ -0,0 +1,33 @@
import '../../../../core/usecases/usecase.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/otp_response.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_remote_data_source.dart';
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
AuthRepositoryImpl({
required this.remoteDataSource,
});
@override
Future<Result<OtpResponse>> sendOTP(String phoneNumber) async {
try {
final result = await remoteDataSource.sendOTP(phoneNumber);
return Result.success(result);
} catch (e) {
return Result.failure(ServerFailure());
}
}
@override
Future<Result<bool>> verifyOTP(String otpCode, String phoneNumber) async {
try {
final result = await remoteDataSource.verifyOTP(otpCode, phoneNumber);
return Result.success(result);
} catch (e) {
return Result.failure(ServerFailure());
}
}
}

View File

@ -0,0 +1,11 @@
class OtpResponse {
final String timeStamp;
final String timeDue;
final String phoneNumber;
const OtpResponse({
required this.timeStamp,
required this.timeDue,
required this.phoneNumber,
});
}

View File

@ -0,0 +1,7 @@
import '../../../../core/usecases/usecase.dart';
import '../entities/otp_response.dart';
abstract class AuthRepository {
Future<Result<OtpResponse>> sendOTP(String phoneNumber);
Future<Result<bool>> verifyOTP(String otpCode, String phoneNumber);
}

View File

@ -0,0 +1,20 @@
import '../../../../core/usecases/usecase.dart';
import '../entities/otp_response.dart';
import '../repositories/auth_repository.dart';
class SendOTP implements UseCase<OtpResponse, SendOTPParams> {
final AuthRepository repository;
SendOTP(this.repository);
@override
Future<Result<OtpResponse>> call(SendOTPParams params) async {
return await repository.sendOTP(params.phoneNumber);
}
}
class SendOTPParams {
final String phoneNumber;
SendOTPParams({required this.phoneNumber});
}

View File

@ -0,0 +1,23 @@
import '../../../../core/usecases/usecase.dart';
import '../repositories/auth_repository.dart';
class VerifyOTP implements UseCase<bool, VerifyOTPParams> {
final AuthRepository repository;
VerifyOTP(this.repository);
@override
Future<Result<bool>> call(VerifyOTPParams params) async {
return await repository.verifyOTP(params.otpCode, params.phoneNumber);
}
}
class VerifyOTPParams {
final String otpCode;
final String phoneNumber;
VerifyOTPParams({
required this.otpCode,
required this.phoneNumber,
});
}

View File

@ -0,0 +1,58 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/send_otp.dart';
import '../../domain/usecases/verify_otp.dart';
part 'auth_event.dart';
part 'auth_state.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final SendOTP sendOTPUseCase;
final VerifyOTP verifyOTPUseCase;
String _timeStamp = "";
String _timeDue = "";
String get timeStamp => _timeStamp;
String get timeDue => _timeDue;
AuthBloc({
required this.sendOTPUseCase,
required this.verifyOTPUseCase,
}) : super(AuthInitial()) {
on<SendOTPEvent>(_onSendOTP);
on<VerifyOTPEvent>(_onVerifyOTP);
}
Future<void> _onSendOTP(SendOTPEvent event, Emitter<AuthState> emit) async {
emit(AuthLoading());
final result = await sendOTPUseCase(SendOTPParams(phoneNumber: event.phoneNumber));
if (result.isSuccess && result.data != null) {
_timeStamp = result.data!.timeStamp;
_timeDue = result.data!.timeDue;
emit(AuthSuccess(
phoneNumber: event.phoneNumber,
timeStamp: _timeStamp,
timeDue: _timeDue,
));
} else {
emit(AuthError('خطا در ارسال کد. لطفاً دوباره تلاش کنید.'));
}
}
Future<void> _onVerifyOTP(VerifyOTPEvent event, Emitter<AuthState> emit) async {
emit(AuthLoading());
final result = await verifyOTPUseCase(VerifyOTPParams(
otpCode: event.otpCode,
phoneNumber: event.phoneNumber,
));
if (result.isSuccess && result.data == true) {
emit(OTPVerified());
} else {
emit(AuthError('کد وارد شده نادرست است. لطفاً دوباره تلاش کنید.'));
}
}
}

View File

@ -0,0 +1,19 @@
part of 'auth_bloc.dart';
abstract class AuthEvent {}
class SendOTPEvent extends AuthEvent {
final String phoneNumber;
SendOTPEvent({required this.phoneNumber});
}
class VerifyOTPEvent extends AuthEvent {
final String otpCode;
final String phoneNumber;
VerifyOTPEvent({
required this.otpCode,
required this.phoneNumber,
});
}

View File

@ -0,0 +1,31 @@
// lib/features/auth/presentation/bloc/auth_state.dart
part of 'auth_bloc.dart';
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthSuccess extends AuthState {
final String phoneNumber;
//
// CHANGE: Made timeStamp and timeDue required.
//
final String timeStamp;
final String timeDue;
AuthSuccess({
required this.phoneNumber,
required this.timeStamp,
required this.timeDue,
});
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
}
class OTPVerified extends AuthState {}

View File

@ -0,0 +1,296 @@
import 'package:country_pickers/country.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:country_pickers/country_pickers.dart';
import '../../../../extension/screenSize.dart';
import '../../../../gen/assets.gen.dart';
import '../bloc/auth_bloc.dart';
import 'otp_verification_page.dart';
import '../../../../widgets/button.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final TextEditingController phoneController = TextEditingController();
final TextEditingController countryController = TextEditingController();
Country _selectedCountry = CountryPickerUtils.getCountryByPhoneCode('971');
bool keepSignedIn = false;
@override
void initState() {
super.initState();
countryController.text = _selectedCountry.name;
}
@override
Widget build(BuildContext context) {
final height = context.screenHeight;
return Scaffold(
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Padding(
padding: const EdgeInsets.only(top: 50),
child: SvgPicture.asset(
Assets.images.logo.path,
height: height / 5.2,
),
),
),
SizedBox(height: height / 25),
const Text(
"Login",
style: TextStyle(fontSize: 35, fontWeight: FontWeight.bold),
),
SizedBox(height: height / 100),
const Text(
"Please enter your phone number",
style: TextStyle(fontSize: 17),
),
const SizedBox(height: 25),
TextField(
controller: countryController,
readOnly: true,
onTap: () => _openCountryPicker(context),
decoration: InputDecoration(
labelText: "Country",
prefixIcon: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 15,
),
child: CountryPickerUtils.getDefaultFlagImage(
_selectedCountry,
),
),
suffixIcon: Padding(
padding: const EdgeInsets.all(12.0),
child: SvgPicture.asset(Assets.icons.arrowDownBlack.path),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(
color: Color.fromARGB(255, 14, 63, 102),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(
color: Color.fromARGB(255, 14, 63, 102),
width: 2,
),
),
),
),
const SizedBox(height: 16),
TextField(
controller: phoneController,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
labelText: "Phone Number",
hintText: "_ _ _ _ _ _ _ _ _ _ _",
labelStyle: TextStyle(color: Colors.black),
prefix: Text(
"+${_selectedCountry.phoneCode} ",
style: const TextStyle(
fontSize: 16,
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(
color: Color.fromARGB(255, 14, 63, 102),
width: 2,
),
),
),
),
const SizedBox(height: 16),
InkWell(
onTap: () {
setState(() {
keepSignedIn = !keepSignedIn;
});
},
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Checkbox(
value: keepSignedIn,
activeColor: const Color.fromARGB(255, 14, 63, 102),
onChanged: (bool? value) {
setState(() {
keepSignedIn = value ?? false;
});
},
),
const Text(
"Keep me signed in",
style: TextStyle(fontSize: 15),
),
],
),
),
SizedBox(height: height / 50),
BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthSuccess) {
Navigator.push(
context,
MaterialPageRoute(
builder:
(context) => OTPVerificationPage(
phoneNumber:
"+${_selectedCountry.phoneCode}${phoneController.text}",
timeDue: state.timeDue,
),
),
);
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
backgroundColor: Colors.red,
content: Text(state.message),
),
);
}
},
builder: (context, state) {
if (state is AuthLoading) {
return const Center(child: CircularProgressIndicator());
}
return Column(
children: [
SizedBox(
width: double.infinity,
height: 48,
child: Button(
color: const Color.fromARGB(255, 30, 137, 221),
text: "Get OTP",
onPressed: () {
context.read<AuthBloc>().add(
SendOTPEvent(
phoneNumber:
"+${_selectedCountry.phoneCode}${phoneController.text}",
),
);
},
),
),
SizedBox(height: height / 50),
Row(
children: [
const Expanded(
child: Divider(thickness: 1, color: Colors.grey),
),
Padding(
padding: EdgeInsets.symmetric(
horizontal: context.screenWidth / 15,
),
child: const Text(
"Or continue with",
style: TextStyle(
fontWeight: FontWeight.normal,
fontSize: 17,
),
),
),
const Expanded(
child: Divider(thickness: 1, color: Colors.grey),
),
],
),
SizedBox(height: height / 30),
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
icon: SvgPicture.asset(Assets.images.googleSvg.path),
label: const Text(
"Login with google",
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.grey),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(32),
),
),
onPressed: () {},
),
),
],
);
},
),
],
),
),
),
);
}
void _openCountryPicker(BuildContext context) {
showDialog(
context: context,
builder:
(context) => Theme(
data: Theme.of(
context,
).copyWith(primaryColor: const Color.fromARGB(255, 14, 63, 102)),
child: CountryPickerDialog(
title: const Text('Select Country'),
searchCursorColor: const Color.fromARGB(255, 14, 63, 102),
searchInputDecoration: const InputDecoration(
hintText: 'Search...',
prefixIcon: Icon(Icons.search),
),
isSearchable: true,
onValuePicked: (Country country) {
setState(() {
_selectedCountry = country;
countryController.text = country.name;
});
},
itemBuilder: _buildDialogItem,
),
),
);
}
Widget _buildDialogItem(Country country) => Row(
children: [
CountryPickerUtils.getDefaultFlagImage(country),
const SizedBox(width: 8),
Text("+${country.phoneCode}"),
const SizedBox(width: 8),
Flexible(child: Text(country.name)),
],
);
}

View File

@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../../../extension/screenSize.dart';
import '../../../../gen/assets.gen.dart';
import '../../../../res/colors.dart';
import 'login_page.dart';
class OnboardingPage extends StatefulWidget {
const OnboardingPage({super.key});
@override
State<OnboardingPage> createState() => _OnboardingPageState();
}
class _OnboardingPageState extends State<OnboardingPage> {
int currentIndex = 0;
final PageController _pageController = PageController();
final List<String> imageAssets = [
Assets.images.ounboarding1.path,
Assets.images.frame.path,
Assets.images.onboarding3.path,
];
final List<String> slides = [
Assets.icons.slides1.path,
Assets.icons.slide2.path,
Assets.icons.slide3.path,
];
void _next() {
if (currentIndex < imageAssets.length - 1) {
_pageController.nextPage(duration: Duration(milliseconds: 300), curve: Curves.easeInOut);
} else {
Navigator.push(context, MaterialPageRoute(builder: (context) => LoginPage()));
}
}
void _back() {
if (currentIndex > 0) {
_pageController.previousPage(duration: Duration(milliseconds: 300), curve: Curves.easeInOut);
}
}
@override
Widget build(BuildContext context) {
final width = context.screenWidth;
final height = context.screenHeight;
return Scaffold(
body: Column(
children: [
Expanded(
child: PageView.builder(
controller: _pageController,
itemCount: imageAssets.length,
onPageChanged: (index) {
setState(() {
currentIndex = index;
});
},
itemBuilder: (context, index) {
return Padding(
padding: EdgeInsets.fromLTRB(width / 15, height / 40, width / 15, height / 30),
child: SvgPicture.asset(
imageAssets[index],
key: ValueKey(imageAssets[index]),
height: height / 2.5,
width: width,
),
);
},
),
),
Padding(
padding: EdgeInsets.fromLTRB(width / 15, 10, width / 15, 5),
child: Row(
children: [
Text(
"Proxibuy Geofencing marketing",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
textDirection: TextDirection.ltr,
),
],
),
),
Padding(
padding: EdgeInsets.fromLTRB(width / 15, 0, width / 15, width / 30),
child: Text(
'"Join the app to discover exclusive discounts and special offers in specific areas around you for a smarter shopping experience!"',
style: TextStyle(fontWeight: FontWeight.w500, color: LightAppColors.hint, fontSize: 15),
),
),
SizedBox(height: height / 30),
Padding(
padding: EdgeInsets.fromLTRB(width / 15, 0, width / 15, height / 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: SvgPicture.asset(
slides[currentIndex],
key: ValueKey(slides[currentIndex]),
),
),
Row(
children: [
if (currentIndex > 0)
GestureDetector(
onTap: _back,
child: SvgPicture.asset(Assets.icons.arrowLeft.path),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _next,
child: SvgPicture.asset(
Assets.icons.next.path,
width: width / 5.5,
),
),
],
)
],
),
)
],
),
);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../../../gen/assets.gen.dart';
import '../../../../widgets/button.dart';
import '../../../../widgets/remainingTime.dart';
import '../bloc/auth_bloc.dart';
import 'user_info_page.dart';
class OTPVerificationPage extends StatefulWidget {
final String phoneNumber;
final String timeDue;
const OTPVerificationPage({
super.key,
required this.phoneNumber,
required this.timeDue,
});
@override
State<OTPVerificationPage> createState() => _OTPVerificationPageState();
}
class _OTPVerificationPageState extends State<OTPVerificationPage> {
final List<FocusNode> _focusNodes = List.generate(5, (_) => FocusNode());
final List<TextEditingController> _controllers =
List.generate(5, (_) => TextEditingController());
late RemainingTime _otpTimer;
@override
void initState() {
super.initState();
_otpTimer = RemainingTime();
_initializeTimer();
}
void _initializeTimer() {
_otpTimer.initializeFromExpiry(expiryTimeString: widget.timeDue);
}
void _resendOTP() {
if (_otpTimer.canResend.value) {
context
.read<AuthBloc>()
.add(SendOTPEvent(phoneNumber: widget.phoneNumber));
}
}
@override
void dispose() {
for (final node in _focusNodes) {
node.dispose();
}
for (final controller in _controllers) {
controller.dispose();
}
_otpTimer.dispose();
super.dispose();
}
Widget _buildOTPFields() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(5, (index) {
return SizedBox(
width: 60,
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 1,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
decoration: InputDecoration(
counterText: '',
hintText: "0",
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.grey),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
const BorderSide(color: Color.fromARGB(255, 14, 63, 102)),
),
),
onChanged: (value) {
if (value.length == 1 && index < 4) {
FocusScope.of(context).requestFocus(_focusNodes[index + 1]);
}
},
),
);
}),
);
}
@override
Widget build(BuildContext context) {
final height = MediaQuery.of(context).size.height;
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthSuccess) {
_otpTimer.initializeFromExpiry(expiryTimeString: state.timeDue);
}
},
child: Scaffold(
body: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: SvgPicture.asset(Assets.icons.back.path),
onPressed: () => Navigator.pop(context),
),
],
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Padding(
padding: const EdgeInsets.only(top: 0),
child: SvgPicture.asset(Assets.images.logo.path,
height: height / 5.2),
),
),
SizedBox(height: height / 20),
const Text("OTP Verification",
style: TextStyle(
fontSize: 33, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text(
"Enter the verification code we just sent to your device.",
style: TextStyle(
fontSize: 17, fontWeight: FontWeight.w500),
),
const SizedBox(height: 25),
_buildOTPFields(),
SizedBox(height: height / 7),
BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is OTPVerified) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const UserInfoPage()),
);
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
backgroundColor: Colors.red,
content: Text(state.message),
),
);
}
},
builder: (context, state) {
if (state is AuthLoading) {
return const Center(
child: CircularProgressIndicator());
}
return SizedBox(
width: double.infinity,
height: 48,
child: Button(
text: "Verify",
onPressed: () {
final otpCode =
_controllers.map((c) => c.text).join();
if (otpCode.length >= 4) {
context.read<AuthBloc>().add(VerifyOTPEvent(
otpCode: otpCode,
phoneNumber: widget.phoneNumber,
));
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
backgroundColor: Colors.red,
content: Text(
'لطفاً کد OTP را کامل وارد کنید'),
),
);
}
},
color: const Color.fromARGB(255, 30, 137, 221),
),
);
},
),
SizedBox(height: height / 25),
Center(
child: Column(
children: [
Row(
children: [
const Expanded(
child: Divider(
thickness: 1, color: Colors.grey),
),
ValueListenableBuilder<int>(
valueListenable: _otpTimer.remainingSeconds,
builder: (context, seconds, _) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0),
child: Text(
"Resend OTP in ${_otpTimer.formatTime()}",
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 18),
),
);
},
),
const Expanded(
child: Divider(
thickness: 1, color: Colors.grey),
),
],
),
const SizedBox(height: 8),
ValueListenableBuilder<bool>(
valueListenable: _otpTimer.canResend,
builder: (context, canResend, _) {
return GestureDetector(
onTap: canResend ? _resendOTP : null,
child: Text(
"Resend OTP",
style: TextStyle(
fontSize: 16,
color: canResend
? const Color.fromARGB(
255, 0, 0, 0)
: Colors.grey,
),
),
);
},
),
],
),
),
],
),
),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../../../extension/screenSize.dart';
import '../../../../gen/assets.gen.dart';
import '../../../../res/colors.dart';
import '../../../../screens/mains/navigation/navigation.dart';
import '../../../../widgets/button.dart';
class UserInfoPage extends StatefulWidget {
const UserInfoPage({super.key});
@override
State<UserInfoPage> createState() => _UserInfoPageState();
}
class _UserInfoPageState extends State<UserInfoPage> {
DateTime? selectedDate;
String? selectedGender;
@override
Widget build(BuildContext context) {
final width = context.screenWidth;
final height = context.screenHeight;
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: SvgPicture.asset(Assets.icons.back.path),
onPressed: () => Navigator.pop(context),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 0,
vertical: 24,
),
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 0),
child: SvgPicture.asset(
Assets.images.userinfo.path,
height: height / 2.9,
),
),
),
),
SizedBox(height: height / 20),
const Padding(
padding: EdgeInsets.fromLTRB(25, 0, 25, 20),
child: Text(
"what do you like to be called in this application?",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Color.fromARGB(255, 117, 117, 117),
),
),
),
const Padding(
padding: EdgeInsets.fromLTRB(25, 0, 25, 0),
child: TextField(
decoration: InputDecoration(
counterText: '',
hintText: "Enter here...",
hintStyle: TextStyle(
fontWeight: FontWeight.normal,
color: Colors.grey,
),
filled: true,
fillColor: Color.fromARGB(255, 250, 250, 250),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(
color: Color.fromARGB(255, 14, 63, 102),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: Colors.grey, width: 2),
),
),
),
),
const SizedBox(height: 24),
const Padding(
padding: EdgeInsets.fromLTRB(25, 0, 25, 10),
child: Text(
"Gender",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Color.fromARGB(255, 117, 117, 117),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Wrap(
spacing: 3,
alignment: WrapAlignment.start,
runSpacing: 2,
runAlignment: WrapAlignment.start,
children: [
Directionality(
textDirection: TextDirection.rtl,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Radio<String>(
value: "Prefer not to say",
groupValue: selectedGender,
activeColor: Colors.blue,
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
onChanged: (value) {
setState(() {
selectedGender = value!;
});
},
),
const Text(
"Prefer not to say",
style: TextStyle(
color: Color.fromARGB(255, 112, 112, 110),
),
),
],
),
),
SizedBox(width: 5,),
Directionality(
textDirection: TextDirection.rtl,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Radio<String>(
value: "Male",
groupValue: selectedGender,
activeColor: Colors.blue,
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
onChanged: (value) {
setState(() {
selectedGender = value!;
});
},
),
const Text(
"Male",
style: TextStyle(
color: Color.fromARGB(255, 112, 112, 110),
),
),
],
),
),
SizedBox(width: 5,),
Directionality(
textDirection: TextDirection.rtl,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Radio<String>(
value: "Female",
groupValue: selectedGender,
activeColor: Colors.blue,
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
onChanged: (value) {
setState(() {
selectedGender = value!;
});
},
),
const Text(
"Female",
style: TextStyle(
color: Color.fromARGB(255, 112, 112, 110),
),
),
],
),
),
],
),
),
SizedBox(height: height / 8),
Center(
child: SizedBox(
width: width * 0.9,
child: Button(
text: "Submit",
onPressed: () {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => const MainScreen(),
),
(route) => false,
);
},
color: const Color.fromARGB(255, 30, 137, 221),
),
),
),
GestureDetector(
onTap: () {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const MainScreen()),
(route) => false,
);
},
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Center(
child: InkWell(
child: Text(
"Skip",
style: TextStyle(
color: LightAppColors.primary,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,35 @@
import 'features/auth/data/datasources/auth_remote_data_source.dart';
import 'features/auth/data/repositories/auth_repository_impl.dart';
import 'features/auth/domain/repositories/auth_repository.dart';
import 'features/auth/domain/usecases/send_otp.dart';
import 'features/auth/domain/usecases/verify_otp.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
class ServiceLocator {
static final ServiceLocator _instance = ServiceLocator._internal();
factory ServiceLocator() => _instance;
ServiceLocator._internal();
late final AuthRemoteDataSource _authRemoteDataSource;
late final AuthRepository _authRepository;
late final SendOTP _sendOTPUseCase;
late final VerifyOTP _verifyOTPUseCase;
void init() {
_authRemoteDataSource = AuthRemoteDataSourceImpl();
_authRepository = AuthRepositoryImpl(
remoteDataSource: _authRemoteDataSource,
);
_sendOTPUseCase = SendOTP(_authRepository);
_verifyOTPUseCase = VerifyOTP(_authRepository);
}
AuthBloc get authBloc => AuthBloc(
sendOTPUseCase: _sendOTPUseCase,
verifyOTPUseCase: _verifyOTPUseCase,
);
}
final sl = ServiceLocator();

View File

@ -1,15 +1,13 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lba/screens/auth/onboarding.dart'; import 'features/auth/presentation/pages/onboarding_page.dart';
import 'package:lba/screens/auth/cubit/auth_cubit.dart'; import 'injection_container.dart';
void main() { void main() {
runApp( sl.init();
BlocProvider( runApp(const MyApp());
create: (context) => AuthCubit(),
child: const MyApp(),
),
);
} }
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
@ -17,38 +15,41 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return BlocProvider(
title: 'LBA', create: (context) => sl.authBloc,
theme: ThemeData( child: MaterialApp(
fontFamily: 'Roboto', title: 'LBA',
scaffoldBackgroundColor: Colors.white, theme: ThemeData(
primaryColor: const Color.fromARGB(255, 14, 63, 102), fontFamily: 'Roboto',
buttonTheme: const ButtonThemeData( scaffoldBackgroundColor: Colors.white,
buttonColor: Color.fromARGB(255, 14, 63, 102), primaryColor: const Color.fromARGB(255, 14, 63, 102),
), buttonTheme: const ButtonThemeData(
appBarTheme: const AppBarTheme( buttonColor: Color.fromARGB(255, 14, 63, 102),
backgroundColor: Color.fromARGB(255, 14, 63, 102), ),
), appBarTheme: const AppBarTheme(
dialogTheme: DialogTheme( backgroundColor: Color.fromARGB(255, 14, 63, 102),
backgroundColor: Colors.white, ),
), dialogTheme: DialogTheme(
dropdownMenuTheme: DropdownMenuThemeData( backgroundColor: Colors.white,
menuStyle: MenuStyle( ),
backgroundColor: MaterialStatePropertyAll(Colors.white), dropdownMenuTheme: DropdownMenuThemeData(
), menuStyle: MenuStyle(
), backgroundColor: MaterialStatePropertyAll(Colors.white),
inputDecorationTheme: const InputDecorationTheme( ),
labelStyle: TextStyle(color: Colors.black), ),
hintStyle: TextStyle(color: Colors.grey), inputDecorationTheme: const InputDecorationTheme(
enabledBorder: OutlineInputBorder( labelStyle: TextStyle(color: Colors.black),
borderSide: BorderSide(color: Colors.grey), hintStyle: TextStyle(color: Colors.grey),
), enabledBorder: OutlineInputBorder(
focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: Colors.grey),
borderSide: BorderSide(color: Color.fromARGB(255, 14, 63, 102), width: 2), ),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Color.fromARGB(255, 14, 63, 102), width: 2),
),
), ),
), ),
home: const OnboardingPage(),
), ),
home: const OnboardingScreen(),
); );
} }
} }

View File

@ -14,14 +14,12 @@ class AuthCubit extends Cubit<AuthState> {
Future<void> sendOTP(String phoneNumber) async { Future<void> sendOTP(String phoneNumber) async {
emit(AuthLoading()); emit(AuthLoading());
// Simulate API delay
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
// Mock data for development
_timeStamp = DateTime.now().millisecondsSinceEpoch.toString(); _timeStamp = DateTime.now().millisecondsSinceEpoch.toString();
_timeDue = DateTime.now().add(const Duration(minutes: 2)).millisecondsSinceEpoch.toString(); _timeDue = DateTime.now().add(const Duration(minutes: 2)).millisecondsSinceEpoch.toString();
emit(AuthSuccess(phoneNumber: phoneNumber)); emit(AuthSuccess(phoneNumber: phoneNumber, timeStamp: _timeStamp, timeDue: _timeDue));
} }
void verifyOTP(String otpCode) {} void verifyOTP(String otpCode) {}

View File

@ -9,7 +9,9 @@ final class AuthLoading extends AuthState {}
final class AuthSuccess extends AuthState { final class AuthSuccess extends AuthState {
final String phoneNumber; final String phoneNumber;
AuthSuccess({required this.phoneNumber}); final String timeStamp;
final String timeDue;
AuthSuccess({required this.phoneNumber, required this.timeStamp, required this.timeDue});
} }
final class AuthError extends AuthState { final class AuthError extends AuthState {

View File

@ -5,7 +5,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:country_pickers/country_pickers.dart'; import 'package:country_pickers/country_pickers.dart';
import 'package:lba/extension/screenSize.dart'; import 'package:lba/extension/screenSize.dart';
import 'package:lba/gen/assets.gen.dart'; import 'package:lba/gen/assets.gen.dart';
import 'package:lba/screens/auth/cubit/auth_cubit.dart'; import 'package:lba/screens/auth/cubit/auth_cubit.dart' as cubit;
import 'package:lba/screens/auth/otpVerifcation.dart'; import 'package:lba/screens/auth/otpVerifcation.dart';
import 'package:lba/widgets/button.dart'; import 'package:lba/widgets/button.dart';
@ -148,9 +148,9 @@ class _LoginState extends State<Login> {
), ),
), ),
SizedBox(height: height / 50), SizedBox(height: height / 50),
BlocConsumer<AuthCubit, AuthState>( BlocConsumer<cubit.AuthCubit, cubit.AuthState>(
listener: (context, state) { listener: (context, state) {
if (state is AuthSuccess) { if (state is cubit.AuthSuccess) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -159,7 +159,7 @@ class _LoginState extends State<Login> {
), ),
); );
} }
if (state is AuthError) { if (state is cubit.AuthError) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
@ -170,7 +170,7 @@ class _LoginState extends State<Login> {
} }
}, },
builder: (context, state) { builder: (context, state) {
if (state is AuthLoading) { if (state is cubit.AuthLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return Column( return Column(
@ -182,7 +182,7 @@ class _LoginState extends State<Login> {
color: const Color.fromARGB(255, 30, 137, 221), color: const Color.fromARGB(255, 30, 137, 221),
text: "Get OTP", text: "Get OTP",
onPressed: () { onPressed: () {
context.read<AuthCubit>().sendOTP( context.read<cubit.AuthCubit>().sendOTP(
"+${_selectedCountry.phoneCode}${phoneController.text}", "+${_selectedCountry.phoneCode}${phoneController.text}",
); );
}, },

View File

@ -90,6 +90,7 @@ class _OTPVerificationState extends State<OTPVerification> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// ignore: unused_local_variable
final width = MediaQuery.of(context).size.width; final width = MediaQuery.of(context).size.width;
final height = MediaQuery.of(context).size.height; final height = MediaQuery.of(context).size.height;

View File

@ -4,9 +4,7 @@ import 'package:lba/extension/screenSize.dart';
import 'package:lba/gen/assets.gen.dart'; import 'package:lba/gen/assets.gen.dart';
import 'package:lba/res/colors.dart'; import 'package:lba/res/colors.dart';
import 'package:lba/screens/mains/navigation/navigation.dart'; import 'package:lba/screens/mains/navigation/navigation.dart';
import 'package:lba/screens/mains/nearby/mainNearby/nearby.dart';
import 'package:lba/widgets/button.dart'; import 'package:lba/widgets/button.dart';
import 'package:lba/widgets/datePicker.dart';
class UserInfo extends StatefulWidget { class UserInfo extends StatefulWidget {
const UserInfo({super.key}); const UserInfo({super.key});

View File

@ -1,3 +1,5 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:lba/gen/assets.gen.dart'; import 'package:lba/gen/assets.gen.dart';

View File

@ -1,3 +1,5 @@
// ignore_for_file: must_be_immutable, unused_local_variable, deprecated_member_use
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:lba/extension/screenSize.dart'; import 'package:lba/extension/screenSize.dart';

View File

@ -1,3 +1,5 @@
// ignore_for_file: library_private_types_in_public_api, avoid_print
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';

View File

@ -1,3 +1,5 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:lba/gen/assets.gen.dart'; import 'package:lba/gen/assets.gen.dart';

View File

@ -1,3 +1,5 @@
// ignore_for_file: must_be_immutable, deprecated_member_use
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:lba/gen/assets.gen.dart'; import 'package:lba/gen/assets.gen.dart';

View File

@ -1,3 +1,5 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:lba/data/model/workingHours.dart'; import 'package:lba/data/model/workingHours.dart';

View File

@ -1,3 +1,5 @@
// ignore_for_file: unused_local_variable, deprecated_member_use
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:lba/data/model/workingHours.dart'; import 'package:lba/data/model/workingHours.dart';

View File

@ -1,3 +1,5 @@
// ignore_for_file: unused_local_variable
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lba/extension/screenSize.dart'; import 'package:lba/extension/screenSize.dart';
import 'package:lba/res/colors.dart'; import 'package:lba/res/colors.dart';

View File

@ -1,3 +1,5 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:lba/gen/assets.gen.dart'; import 'package:lba/gen/assets.gen.dart';
@ -16,7 +18,7 @@ class CustomCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: 320, // عرض کمی افزایش یافت width: 320,
margin: const EdgeInsets.symmetric(horizontal: 5), margin: const EdgeInsets.symmetric(horizontal: 5),
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration( decoration: BoxDecoration(

View File

@ -1,3 +1,5 @@
// ignore_for_file: use_build_context_synchronously, deprecated_member_use
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';

View File

@ -1,3 +1,5 @@
// ignore_for_file: prefer_final_fields
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:lba/gen/assets.gen.dart'; import 'package:lba/gen/assets.gen.dart';

View File

@ -1,3 +1,5 @@
// ignore_for_file: must_be_immutable, deprecated_member_use
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:lba/res/colors.dart'; import 'package:lba/res/colors.dart';

View File

@ -9,7 +9,7 @@ class RemainingTime {
void initializeFromExpiry({required String expiryTimeString}) { void initializeFromExpiry({required String expiryTimeString}) {
try { try {
_expiryTime = DateTime.parse(expiryTimeString).toUtc(); _expiryTime = DateTime.fromMillisecondsSinceEpoch(int.parse(expiryTimeString)).toUtc();
_updateRemainingSeconds(); _updateRemainingSeconds();
startTimer(); startTimer();
} catch (e) { } catch (e) {
@ -21,10 +21,10 @@ class RemainingTime {
void _updateRemainingSeconds() { void _updateRemainingSeconds() {
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
if (_expiryTime == null) return;
final difference = _expiryTime!.difference(now).inSeconds; final difference = _expiryTime!.difference(now).inSeconds;
remainingSeconds.value = difference > 0 ? difference : 0; remainingSeconds.value = difference > 0 ? difference : 0;
canResend.value = remainingSeconds.value <= 0; canResend.value = remainingSeconds.value <= 0;
// debugPrint("Remaining seconds: ${remainingSeconds.value}");
} }
void startTimer() { void startTimer() {