import 'package:flutter/cupertino.dart'; import 'package:please_pay_me/core/branding/app_brand.dart'; import 'package:please_pay_me/data/api/api_client.dart'; import 'package:please_pay_me/features/auth/session_controller.dart'; import 'package:please_pay_me/features/auth/telegram_login.dart'; import 'package:please_pay_me/features/auth/yandex_login.dart'; import 'package:please_pay_me/features/legal/legal_consent.dart'; import 'package:please_pay_me/theme/theme.dart'; import 'package:please_pay_me/ui/ui.dart'; import 'package:provider/provider.dart'; /// Opens the cabinet and returns the harvested JWT. typedef TelegramLoginLauncher = Future Function( BuildContext context, String cabinetUrl, ); /// Opens Yandex OAuth and returns the JWT issued by `/api/auth/yandex`. typedef YandexLoginLauncher = Future Function( BuildContext context, { required String clientId, required String redirectUri, }); /// Sign-in screen. /// /// Cabinet / API origin comes from `.env` (`PPM_WEB_URL`, `PPM_API_BASE_URL`). /// Telegram: cabinet WebView + JWT channel. Yandex: OAuth code in a WebView, /// exchanged on the API so the client secret never leaves the server. class LoginScreen extends StatefulWidget { const LoginScreen({ super.key, this.launchTelegramLogin, this.launchYandexLogin, }); /// Injection point for tests and Widgetbook, where no WebView exists. final TelegramLoginLauncher? launchTelegramLogin; final YandexLoginLauncher? launchYandexLogin; @override State createState() => _LoginScreenState(); } class _LoginScreenState extends State { final _tokenController = TextEditingController(); bool _busy = false; late bool _tokenMode = !_webLoginAvailable; YandexAuthProvider? _yandex; String? _providersError; LegalAcceptance _legal = const LegalAcceptance(); bool get _webLoginAvailable => widget.launchTelegramLogin != null || isTelegramWebLoginSupported; bool get _yandexLoginAvailable => widget.launchYandexLogin != null || isYandexWebLoginSupported; String _cabinetUrl(SessionController session) { if (session.webCabinetUrl.isNotEmpty) return session.webCabinetUrl; return session.baseUrl; } String _apiBase(SessionController session) { return resolveApiBaseUrl( cabinetUrl: _cabinetUrl(session), configuredApiBaseUrl: session.configuredApiBaseUrl, ); } @override void initState() { super.initState(); _loadProviders(); } @override void dispose() { _tokenController.dispose(); super.dispose(); } Future _loadProviders() async { final session = context.read(); final apiBase = _apiBase(session); if (apiBase.isEmpty) { if (mounted) { setState(() { _providersError = 'Не задан PPM_API_BASE_URL в .env'; }); } else { _providersError = 'Не задан PPM_API_BASE_URL в .env'; } return; } try { final providers = await fetchAuthProviders(apiBase, httpClient: session.httpClient); if (!mounted) return; setState(() { _yandex = providers.yandex; _providersError = providers.yandex?.usable == true ? null : 'API не включил Яндекс (нет YANDEX_CLIENT_ID/SECRET на сервере)'; }); } on ApiException catch (error) { if (!mounted) return; setState(() { _yandex = null; _providersError = error.message; }); } catch (error) { if (!mounted) return; setState(() { _yandex = null; _providersError = error.toString(); }); } } Future _ensureCabinetUrl(SessionController session) async { if (_cabinetUrl(session).isNotEmpty) return true; await showAppToast( context, message: 'Задайте PPM_WEB_URL в mobile/.env', icon: CupertinoIcons.exclamationmark_circle_fill, ); return false; } @override Widget build(BuildContext context) { final session = context.watch(); return CupertinoPageScaffold( backgroundColor: AppColors.of(context, AppColors.groupedBackground), child: SafeArea( child: ListView( padding: const EdgeInsets.only(top: AppSpacing.s7, bottom: AppSpacing.s6), children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: AppSpacing.gutter), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AppIcon( CupertinoIcons.money_rubl_circle_fill, size: 48, color: AppColors.accent, ), SizedBox(height: AppSpacing.s3), AppText.largeTitle(AppBrand.name), SizedBox(height: AppSpacing.s1), AppText.subhead( 'Бюджет от зарплаты до зарплаты. Войдите через Яндекс.', ), ], ), ), const SizedBox(height: AppSpacing.s6), Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ LegalConsentBlock( value: _legal, cabinetUrl: _cabinetUrl(session), onChanged: (next) => setState(() => _legal = next), ), const SizedBox(height: AppSpacing.s5), if (_tokenMode) ...[ AppTextField( label: 'Токен доступа', placeholder: 'eyJhbGciOi…', controller: _tokenController, enabled: !_busy, errorText: session.lastError, ), const SizedBox(height: AppSpacing.s5), AppButton( label: 'Войти по токену', loading: _busy, onPressed: _legal.accepted ? _signInWithToken : null, ), ] else ...[ if (session.lastError != null) ...[ AppText.footnote(session.lastError!, color: AppColors.systemRed), const SizedBox(height: AppSpacing.s3), ], const SizedBox(height: AppSpacing.s3), AppButton( label: 'Войти через Яндекс', style: AppButtonStyle.gray, loading: _busy, onPressed: _legal.accepted ? _signInWithYandex : null, ), ], ], ), ), const SizedBox(height: AppSpacing.s5), const AppListSection( header: 'Что умеет', footer: 'Один кабинет на телефоне и в браузере — бюджет не разъедется.', children: [ AppListTile( leading: AppIcon(CupertinoIcons.calendar, color: AppColors.accent), title: 'Бюджет от зарплаты до зарплаты', subtitle: 'Остаток и дневной лимит на каждый день периода', showChevron: false, ), AppListTile( leading: AppIcon(CupertinoIcons.money_rubl, color: AppColors.accent), title: 'Траты в один тап', subtitle: 'Журнал по дням, несколько конвертов параллельно', showChevron: false, ), AppListTile( leading: AppIcon(CupertinoIcons.briefcase, color: AppColors.accent), title: 'График выплат', subtitle: 'Оклад, дни зарплаты и правило выходных', showChevron: false, ), ], ), ], ), ), ); } Future _signInWithTelegram() async { final session = context.read(); if (!await _ensureCabinetUrl(session)) return; final cabinetUrl = _cabinetUrl(session); final launcher = widget.launchTelegramLogin ?? (ctx, url) => showTelegramLogin(context: ctx, cabinetUrl: url); setState(() => _busy = true); final token = await launcher(context, cabinetUrl); if (!mounted) return; if (token == null || token.isEmpty) { setState(() => _busy = false); return; } await session.signInWithToken(baseUrl: _apiBase(session), token: token); if (mounted) setState(() => _busy = false); } Future _signInWithYandex() async { final session = context.read(); if (!await _ensureCabinetUrl(session)) return; if (_yandex == null || !(_yandex?.usable ?? false)) { setState(() => _busy = true); await _loadProviders(); if (mounted) setState(() => _busy = false); } final provider = _yandex; if (provider == null || !provider.usable) { await showAppToast( context, message: _providersError ?? 'Вход через Яндекс недоступен', icon: CupertinoIcons.exclamationmark_circle_fill, ); return; } if (!_yandexLoginAvailable) { await showAppToast( context, message: 'Яндекс в приложении работает на телефоне. На Windows откройте кабинет в браузере.', icon: CupertinoIcons.device_phone_portrait, ); return; } final cabinetUrl = _cabinetUrl(session); final apiBase = _apiBase(session); final redirectUri = cabinetYandexRedirectUri( cabinetUrl, configured: provider.redirectUri, ); setState(() => _busy = true); final launcher = widget.launchYandexLogin; final token = launcher != null ? await launcher(context, clientId: provider.clientId, redirectUri: redirectUri) : await showYandexLogin( context: context, clientId: provider.clientId, redirectUri: redirectUri, exchangeCode: (code) => exchangeYandexCode( apiBaseUrl: apiBase, code: code, redirectUri: redirectUri, httpClient: session.httpClient, ), ); if (!mounted) return; if (token == null || token.isEmpty) { setState(() => _busy = false); return; } await session.signInWithToken(baseUrl: apiBase, token: token); if (mounted) setState(() => _busy = false); } Future _signInWithToken() async { final session = context.read(); if (!await _ensureCabinetUrl(session)) return; setState(() => _busy = true); await session.signInWithToken( baseUrl: _apiBase(session), token: _tokenController.text, ); if (mounted) setState(() => _busy = false); } }