feat: implement splash and onboarding flows, update app assets, and add project design documentation

This commit is contained in:
Hamza-Ayed
2026-07-24 04:15:07 +03:00
parent 9f7bc31408
commit 213b459912
208 changed files with 23801 additions and 1066 deletions
@@ -8,13 +8,34 @@ class AuthCubit extends Cubit<AuthState> {
AuthCubit(this._repo) : super(const AuthState());
Future<void> checkAuth() async {
final hasToken = await _repo.hasSavedToken();
if (hasToken) {
final cachedUser = await _repo.getCachedUser();
if (cachedUser != null) {
emit(state.copyWith(
status: AuthStatus.authenticated,
user: cachedUser,
));
}
}
final user = await _repo.me();
emit(state.copyWith(
status: user != null ? AuthStatus.authenticated : AuthStatus.unauthenticated,
user: user,
));
if (user != null) {
emit(state.copyWith(
status: AuthStatus.authenticated,
user: user,
));
} else {
final hasTokenAfterMe = await _repo.hasSavedToken();
if (!hasTokenAfterMe) {
emit(state.copyWith(
status: AuthStatus.unauthenticated,
user: null,
));
}
}
}
Future<void> sendOtp(String phone) async {
if (phone.trim().isEmpty) {
emit(state.copyWith(status: AuthStatus.error, error: 'أدخل رقم الهاتف'));
@@ -43,4 +64,14 @@ class AuthCubit extends Cubit<AuthState> {
await _repo.logout();
emit(const AuthState(status: AuthStatus.unauthenticated));
}
Future<void> updateName(String name) async {
try {
emit(state.copyWith(status: AuthStatus.authenticating));
final user = await _repo.updateName(name);
emit(state.copyWith(status: AuthStatus.authenticated, user: user));
} catch (_) {
emit(state.copyWith(status: AuthStatus.error, error: 'تعذّر حفظ الاسم'));
}
}
}
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../core/api/api_client.dart';
import '../../../core/storage/token_store.dart';
@@ -16,6 +17,13 @@ class AppUser {
name: j['name'] as String?,
role: (j['role'] ?? 'rider') as String,
);
Map<String, dynamic> toJson() => {
'id': id,
'phone': phone,
'name': name,
'role': role,
};
}
class AuthRepository {
@@ -37,17 +45,42 @@ class AuthRepository {
r.data['access_token'] as String,
r.data['refresh_token'] as String,
);
return AppUser.fromJson(Map<String, dynamic>.from(r.data['user'] as Map));
final user = AppUser.fromJson(Map<String, dynamic>.from(r.data['user'] as Map));
await _store.saveUser(jsonEncode(user.toJson()));
return user;
}
Future<AppUser?> me() async {
Future<AppUser?> getCachedUser() async {
final raw = await _store.readUser();
if (raw == null || raw.isEmpty) return null;
try {
final r = await _api.dio.get('/users/me');
return AppUser.fromJson(Map<String, dynamic>.from(r.data as Map));
} on DioException {
final map = jsonDecode(raw) as Map<String, dynamic>;
return AppUser.fromJson(map);
} catch (_) {
return null;
}
}
Future<bool> hasSavedToken() => _store.hasToken();
Future<AppUser?> me() async {
try {
final r = await _api.dio.get('/users/me');
final user = AppUser.fromJson(Map<String, dynamic>.from(r.data as Map));
await _store.saveUser(jsonEncode(user.toJson()));
return user;
} on DioException {
return getCachedUser();
}
}
Future<void> logout() => _store.clear();
Future<AppUser> updateName(String name) async {
final r = await _api.dio.patch('/users/me', data: {'name': name});
final user = AppUser.fromJson(Map<String, dynamic>.from(r.data as Map));
await _store.saveUser(jsonEncode(user.toJson()));
return user;
}
}
@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/l10n/app_localizations.dart';
import '../../../core/design/tokens.dart';
import '../cubit/auth_cubit.dart';
import '../cubit/auth_state.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_text_field.dart';
/// شاشة إدخال الاسم (أول دخول فقط) — تطابق stitch/name_entry.html:
/// شعار صغير بالمنتصف أعلى الشاشة + عنوان وسطر فرعي + بطاقة بيضاء
/// مرفوعة فيها حقل الاسم بأيقونة، وزر متابعة أسفل الشاشة.
///
/// ملاحظة: تصميم Stitch يعرض 3 حقول (الاسم الأول/الأخير/البريد)، لكن
/// AuthCubit.updateName ينادي PATCH /users/me بحقل name واحد فقط
/// (docs/29 §3.5 ينص صراحة على «حقل واحد»). الشاشة هنا تستعمل حقلاً
/// واحداً للاسم الكامل بنفس لغة Stitch البصرية (بطاقة مرفوعة + أيقونة
/// + label عائم) دون تغيير عقد الـCubit.
class NameScreen extends StatefulWidget {
const NameScreen({super.key});
@override
State<NameScreen> createState() => _NameScreenState();
}
class _NameScreenState extends State<NameScreen> {
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
bool _valid = false;
@override
void initState() {
super.initState();
_firstNameController.addListener(_validate);
_lastNameController.addListener(_validate);
}
void _validate() {
final isValid = _firstNameController.text.trim().isNotEmpty &&
_lastNameController.text.trim().isNotEmpty;
if (_valid != isValid) {
setState(() => _valid = isValid);
}
}
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
super.dispose();
}
void _submit() {
if (!_valid) return;
final fullName =
'${_firstNameController.text.trim()} ${_lastNameController.text.trim()}';
context.read<AuthCubit>().updateName(fullName);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final cs = theme.colorScheme;
return BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.error && state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
final isLoading = state.status == AuthStatus.authenticating;
return TripzScaffold(
backgroundColor: cs.surfaceContainerLow,
padding: false,
bottomAction: TripzButton(
onPressed: (_valid && !isLoading) ? _submit : null,
label: l10n.nameButton,
loading: isLoading,
),
child: Column(
children: [
// ── رأس الشاشة: زر عودة (يبدأ الجلسة من جديد) + شعار مصغّر ──
SizedBox(
height: T.s48,
child: Row(
children: [
IconButton(
icon: const BackButtonIcon(),
color: cs.onSurface,
tooltip: l10n.back,
onPressed: () => context.read<AuthCubit>().logout(),
),
Expanded(
child: Center(
child: Image.asset(
'assets/images/name_hero.png',
height: T.iconLg,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
Icons.directions_car_filled_rounded,
size: T.iconLg,
color: cs.primary,
),
),
),
),
const SizedBox(width: T.s48),
],
),
),
const SizedBox(height: T.s24),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
child: Column(
children: [
// العنوان والسطر الفرعي
Text(
l10n.nameTitle,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: T.s8),
Text(
l10n.nameSubtitle,
style: theme.textTheme.bodyLarge?.copyWith(
color: cs.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: T.s24),
// بطاقة بيضاء مرفوعة تحوي حقول الاسم الأول واسم العائلة
Container(
width: double.infinity,
padding: const EdgeInsets.all(T.s24),
decoration: BoxDecoration(
color: cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular(T.r16),
boxShadow: const [T.shadowSm],
),
child: Column(
children: [
TripzTextField(
controller: _firstNameController,
label: l10n.firstNameHint,
autofocus: true,
prefix: Icon(Icons.person_outline_rounded, color: cs.outline),
textInputAction: TextInputAction.next,
),
const SizedBox(height: T.s16),
TripzTextField(
controller: _lastNameController,
label: l10n.lastNameHint,
prefix: Icon(Icons.badge_outlined, color: cs.outline),
onSubmitted: (_) => _submit(),
),
],
),
),
],
),
),
),
],
),
);
},
);
}
}
+273 -43
View File
@@ -1,13 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import '../cubit/auth_cubit.dart';
import '../cubit/auth_state.dart';
import '../../../core/design/tokens.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_text_field.dart';
/// شاشة تحقق OTP — تطابق stitch/otp_verification.html:
/// شريط علوي بلون primary + 4 خانات منفصلة (ترتيب LTR ثابت) + عدّاد
/// إعادة إرسال + تأكيد تلقائي عند اكتمال الخانات.
class OtpScreen extends StatefulWidget {
const OtpScreen({super.key});
@override
@@ -15,64 +20,289 @@ class OtpScreen extends StatefulWidget {
}
class _OtpScreenState extends State<OtpScreen> {
final _controller = TextEditingController();
static const _otpLength = 4;
final List<TextEditingController> _controllers =
List.generate(_otpLength, (_) => TextEditingController());
final List<FocusNode> _focusNodes =
List.generate(_otpLength, (_) => FocusNode());
int _resendSeconds = 60;
Timer? _resendTimer;
@override
void initState() {
super.initState();
_startResendTimer();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNodes[0].requestFocus();
});
}
@override
void dispose() {
_controller.dispose();
_resendTimer?.cancel();
for (final c in _controllers) {
c.dispose();
}
for (final f in _focusNodes) {
f.dispose();
}
super.dispose();
}
void _startResendTimer() {
_resendSeconds = 60;
_resendTimer?.cancel();
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_resendSeconds <= 0) {
timer.cancel();
} else {
setState(() => _resendSeconds--);
}
});
}
String get _otpCode => _controllers.map((c) => c.text).join();
bool get _isComplete => _otpCode.length == _otpLength;
void _onDigitChanged(int index, String value) {
if (value.length > 1) {
// لصق تلقائي: يوزّع الأرقام الملصوقة على الخانات بالترتيب
final digits = value.replaceAll(RegExp(r'[^0-9]'), '');
for (int i = 0; i < _otpLength && i < digits.length; i++) {
_controllers[i].text = digits[i];
}
final nextIdx = digits.length.clamp(0, _otpLength - 1);
_focusNodes[nextIdx].requestFocus();
} else if (value.isNotEmpty) {
if (index < _otpLength - 1) {
_focusNodes[index + 1].requestFocus();
} else {
_focusNodes[index].unfocus();
}
}
setState(() {});
if (_isComplete) {
_submit();
}
}
void _onKeyPress(int index, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace &&
_controllers[index].text.isEmpty &&
index > 0) {
_controllers[index - 1].clear();
_focusNodes[index - 1].requestFocus();
setState(() {});
}
}
void _submit() {
if (!_isComplete) return;
context.read<AuthCubit>().verify(_otpCode);
}
void _resend() {
final phone = context.read<AuthCubit>().state.phone;
context.read<AuthCubit>().sendOtp(phone);
_startResendTimer();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final cs = theme.colorScheme;
final phone = context.watch<AuthCubit>().state.phone;
return TripzScaffold(
showBackButton: true,
onBack: () => context.go('/phone'),
child: BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.error && state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
return Column(
return BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.error && state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
final isLoading = state.status == AuthStatus.authenticating;
return TripzScaffold(
padding: false,
safeAreaTop: false,
bottomAction: TripzButton(
onPressed: (_isComplete && !isLoading) ? _submit : null,
label: l10n.confirm,
loading: isLoading,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
Text(
l10n.otpTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
l10n.otpSentTo(state.phone),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
// ── شريط علوي بلون primary (يطابق TopAppBar في stitch) ──
SafeArea(
bottom: false,
child: SizedBox(
height: kToolbarHeight,
child: ColoredBox(
color: cs.primary,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: T.s8),
child: Row(
children: [
IconButton(
icon: const BackButtonIcon(),
color: cs.onPrimary,
onPressed: () => context.go('/phone'),
),
const SizedBox(width: T.s8),
Text(
l10n.confirmPhoneNumber,
style: theme.textTheme.titleLarge?.copyWith(
color: cs.onPrimary,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
),
const SizedBox(height: 28),
TripzTextField(
controller: _controller,
label: l10n.otpLabel,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
),
const SizedBox(height: 20),
TripzButton(
label: l10n.confirm,
loading: state.status == AuthStatus.authenticating,
onPressed: () =>
context.read<AuthCubit>().verify(_controller.text),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: T.s24),
// نص الشرح + تعديل الرقم
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
l10n.otpSentTo(phone),
style: theme.textTheme.bodyLarge?.copyWith(
color: cs.onSurfaceVariant,
height: 1.5,
),
),
),
TextButton(
onPressed: () => context.go('/phone'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: T.s8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
l10n.edit,
style: theme.textTheme.labelLarge?.copyWith(
color: cs.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: T.s32),
// ── خانات الرمز — ترتيب LTR ثابت (يطابق dir="ltr" في stitch) ──
Directionality(
textDirection: TextDirection.ltr,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_otpLength, (i) {
final hasFocus = _focusNodes[i].hasFocus;
final hasValue = _controllers[i].text.isNotEmpty;
return Padding(
padding: EdgeInsetsDirectional.only(
end: i < _otpLength - 1 ? T.s16 : 0,
),
child: Container(
width: 60,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(T.r12),
boxShadow: (hasFocus || hasValue) ? const [T.shadowSm] : null,
),
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (event) => _onKeyPress(i, event),
child: TextField(
controller: _controllers[i],
focusNode: _focusNodes[i],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: cs.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: cs.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(T.r12),
borderSide: BorderSide(color: cs.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(T.r12),
borderSide: BorderSide(
color: hasValue ? cs.primary : cs.outlineVariant,
width: hasValue ? 1.5 : 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(T.r12),
borderSide: BorderSide(color: cs.primary, width: 2),
),
),
onChanged: (v) => _onDigitChanged(i, v),
),
),
),
);
}),
),
),
const SizedBox(height: T.s24),
// عدّاد إعادة الإرسال
Center(
child: _resendSeconds > 0
? Text(
l10n.resendIn(_resendSeconds),
style: theme.textTheme.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
)
: TripzButton(
onPressed: _resend,
style: TripzButtonStyle.text,
label: l10n.resendCode,
fullWidth: false,
),
),
],
),
),
),
],
);
},
),
),
);
},
);
}
}
@@ -1,13 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import '../../../core/build_config.dart';
import '../cubit/auth_cubit.dart';
import '../cubit/auth_state.dart';
import '../../../core/design/tokens.dart';
import '../../../core/utils/phone_normalizer.dart';
import '../../../core/ui/phone_intel/intl_phone_field.dart';
import '../../../core/ui/phone_intel/phone_number.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_text_field.dart';
/// شاشة إدخال الهاتف — تطابق stitch/phone_login.html:
/// خلفية فاتحة + شعار بالمنتصف بالأعلى + sheet أبيض سفلي بحواف مدورة
/// فيه حقل الهاتف (علم + كود دولة + رقم) وزر إرسال الكود.
class PhoneScreen extends StatefulWidget {
const PhoneScreen({super.key});
@override
@@ -15,64 +21,156 @@ class PhoneScreen extends StatefulWidget {
}
class _PhoneScreenState extends State<PhoneScreen> {
final _controller = TextEditingController();
PhoneNumber? _phoneNumber;
bool _valid = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
void _sendOtp(BuildContext context) {
if (_phoneNumber == null) return;
final normalized = PhoneNormalizer.normalize(
_phoneNumber!.number,
isoCode: _phoneNumber!.countryISOCode,
);
context.read<AuthCubit>().sendOtp(normalized);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final cs = theme.colorScheme;
return TripzScaffold(
safeAreaTop: true,
padding: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(flex: 2),
Text(
l10n.appName,
style: Theme.of(context).textTheme.headlineLarge,
return BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.error && state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
final isLoading = state.status == AuthStatus.authenticating;
return TripzScaffold(
backgroundColor: cs.surfaceContainerLow,
padding: false,
safeAreaBottom: false,
bottomAction: TripzButton(
onPressed: (_valid && !isLoading) ? () => _sendOtp(context) : null,
label: l10n.sendCode,
loading: isLoading,
),
const SizedBox(height: 8),
Text(
l10n.phoneTitle,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
child: Column(
children: [
// ── منطقة الشعار (النصف العلوي) ──
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: T.s32),
child: Image.asset(
'assets/images/login_hero.png',
width: 200,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
Icons.directions_car_filled_rounded,
size: 96,
color: cs.primary,
),
),
),
),
),
// ── بطاقة الإدخال السفلية (منطقة الإبهام) ──
Container(
width: double.infinity,
decoration: BoxDecoration(
color: cs.surfaceContainerLowest,
borderRadius: const BorderRadius.vertical(top: Radius.circular(T.r24)),
boxShadow: const [T.shadowLg],
),
padding: const EdgeInsets.fromLTRB(T.pagePadding, T.s16, T.pagePadding, T.s32),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// مقبض السحب الزخرفي
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.outlineVariant,
borderRadius: BorderRadius.circular(T.rCircle),
),
),
),
const SizedBox(height: T.s24),
// العنوان
Text(
l10n.loginTitle,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
),
const SizedBox(height: T.s8),
Text(
l10n.phoneLoginSubtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
),
),
const SizedBox(height: T.s24),
// حقل الهاتف — علم + كود الدولة (افتراضي BuildConfig.defaultCountry) + الرقم
IntlPhoneField(
initialCountryCode: BuildConfig.defaultCountry,
languageCode: Localizations.localeOf(context).languageCode,
textAlign: TextAlign.left,
style: theme.textTheme.bodyLarge,
dropdownTextStyle: theme.textTheme.bodyLarge,
flagsButtonPadding: const EdgeInsets.symmetric(horizontal: T.s12),
dropdownDecoration: BoxDecoration(
border: BorderDirectional(
end: BorderSide(color: cs.outlineVariant),
),
),
decoration: InputDecoration(
hintText: l10n.phoneHint,
filled: true,
fillColor: cs.surfaceContainerLowest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(T.r8),
borderSide: BorderSide(color: cs.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(T.r8),
borderSide: BorderSide(color: cs.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(T.r8),
borderSide: BorderSide(color: cs.primary, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: T.s16, vertical: T.s16),
),
onChanged: (phone) {
_phoneNumber = phone;
final num = phone.number;
_valid = num.isNotEmpty && num.length >= 7;
setState(() {});
},
),
],
),
),
),
],
),
const Spacer(),
TripzTextField(
controller: _controller,
label: l10n.phoneLabel,
hint: l10n.phoneHint,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 20),
BlocConsumer<AuthCubit, AuthState>(
listener: (context, state) {
if (state.status == AuthStatus.error && state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
builder: (context, state) {
return TripzButton(
label: l10n.sendCode,
loading: state.status == AuthStatus.authenticating,
onPressed: () =>
context.read<AuthCubit>().sendOtp(_controller.text),
);
},
),
const Spacer(flex: 2),
],
),
);
},
);
}
}