This commit is contained in:
2026-06-15 16:42:28 +03:30
commit 068930c7bd
158 changed files with 5330 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'auth_repository.dart';
enum AuthStatus { initial, loading, otpSent, authenticated, error }
class AuthState extends Equatable {
final AuthStatus status;
final String mobile;
final String? error;
const AuthState({
this.status = AuthStatus.initial,
this.mobile = '',
this.error,
});
AuthState copyWith({AuthStatus? status, String? mobile, String? error}) =>
AuthState(
status: status ?? this.status,
mobile: mobile ?? this.mobile,
error: error,
);
@override
List<Object?> get props => [status, mobile, error];
}
class AuthCubit extends Cubit<AuthState> {
final AuthRepository _repo;
AuthCubit(this._repo) : super(const AuthState());
Future<void> requestOtp(String mobile) async {
emit(state.copyWith(status: AuthStatus.loading, mobile: mobile));
try {
await _repo.requestOtp(mobile);
emit(state.copyWith(status: AuthStatus.otpSent, mobile: mobile));
} catch (e) {
emit(state.copyWith(status: AuthStatus.error, error: _msg(e)));
}
}
Future<void> verifyOtp(String code) async {
emit(state.copyWith(status: AuthStatus.loading));
try {
await _repo.verifyOtp(state.mobile, code);
emit(state.copyWith(status: AuthStatus.authenticated));
} catch (e) {
emit(state.copyWith(status: AuthStatus.error, error: _msg(e)));
}
}
Future<void> logout() async {
await _repo.logout();
emit(const AuthState());
}
/// بازنشانی وضعیت خطا به حالت مناسب فرم.
void resetError({required bool onOtpScreen}) {
emit(state.copyWith(
status: onOtpScreen ? AuthStatus.otpSent : AuthStatus.initial));
}
String _msg(Object e) {
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data['message'] != null) {
return data['message'].toString();
}
return 'خطا در ارتباط با سرور';
}
return 'خطای نامشخص';
}
}
+35
View File
@@ -0,0 +1,35 @@
import '../../core/network/api_client.dart';
import '../../core/storage/token_storage.dart';
/// دسترسی به endpointهای احراز هویت (login-otp / check-otp).
class AuthRepository {
final ApiClient _api;
final TokenStorage _storage;
AuthRepository(this._api, this._storage);
/// درخواست ارسال کد یک‌بارمصرف.
Future<void> requestOtp(String mobile) async {
await _api.dio.post('/auth/login-otp', data: {'mobile': mobile});
}
/// اعتبارسنجی کد و ذخیره‌ی توکن JWT.
Future<void> verifyOtp(String mobile, String code) async {
final res = await _api.dio.post(
'/auth/check-otp',
data: {'mobile': mobile, 'token': code},
);
final token = res.data['token'] as String?;
if (token == null || token.isEmpty) {
throw Exception('no token in response');
}
await _storage.write(token);
}
Future<bool> isLoggedIn() async {
final t = await _storage.read();
return t != null && t.isNotEmpty;
}
Future<void> logout() => _storage.clear();
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import 'auth_cubit.dart';
/// صفحه‌ی ورود شماره موبایل.
class MobileScreen extends StatefulWidget {
const MobileScreen({super.key});
@override
State<MobileScreen> createState() => _MobileScreenState();
}
class _MobileScreenState extends State<MobileScreen> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
bool get _valid => RegExp(r'^09\d{9}$').hasMatch(_controller.text.trim());
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.otpSent) {
context.push('/otp');
} else if (state.status == AuthStatus.error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error ?? 'خطا')),
);
}
},
builder: (context, state) {
final loading = state.status == AuthStatus.loading;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('سلطان حکم',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: AppColors.gold)),
const SizedBox(height: 8),
const Text('برای ورود شماره موبایلت رو وارد کن',
style: TextStyle(color: Colors.white70)),
const SizedBox(height: 32),
TextField(
controller: _controller,
keyboardType: TextInputType.phone,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20, letterSpacing: 2),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(11),
],
decoration: const InputDecoration(hintText: '09xxxxxxxxx'),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: (!_valid || loading)
? null
: () => context
.read<AuthCubit>()
.requestOtp(_controller.text.trim()),
child: loading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('دریافت کد'),
),
],
),
);
},
),
),
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import 'auth_cubit.dart';
/// صفحه‌ی ورود کد یک‌بارمصرف (۵ رقمی).
class OtpScreen extends StatefulWidget {
const OtpScreen({super.key});
@override
State<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends State<OtpScreen> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
bool get _valid => _controller.text.trim().length == 5;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('تأیید کد')),
body: SafeArea(
child: BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.authenticated) {
context.go('/lobby');
} else if (state.status == AuthStatus.error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error ?? 'خطا')),
);
context.read<AuthCubit>().resetError(onOtpScreen: true);
}
},
builder: (context, state) {
final loading = state.status == AuthStatus.loading;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('کد پیامک‌شده به ${state.mobile} را وارد کنید',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 32),
TextField(
controller: _controller,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 28, letterSpacing: 12),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(5),
],
decoration: const InputDecoration(hintText: '- - - - -'),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: (!_valid || loading)
? null
: () => context
.read<AuthCubit>()
.verifyOtp(_controller.text.trim()),
child: loading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('ورود'),
),
TextButton(
onPressed: loading ? null : () => context.pop(),
child: const Text('تغییر شماره',
style: TextStyle(color: AppColors.gold)),
),
],
),
);
},
),
),
);
}
}