Files
please-pay-me/mobile/lib/features/auth/login_screen.dart
T
2026-09-21 04:06:43 +03:00

332 lines
11 KiB
Dart

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<String?> Function(
BuildContext context,
String cabinetUrl,
);
/// Opens Yandex OAuth and returns the JWT issued by `/api/auth/yandex`.
typedef YandexLoginLauncher = Future<String?> 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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
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<void> _loadProviders() async {
final session = context.read<SessionController>();
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<bool> _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<SessionController>();
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<void> _signInWithTelegram() async {
final session = context.read<SessionController>();
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<void> _signInWithYandex() async {
final session = context.read<SessionController>();
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<void> _signInWithToken() async {
final session = context.read<SessionController>();
if (!await _ensureCabinetUrl(session)) return;
setState(() => _busy = true);
await session.signInWithToken(
baseUrl: _apiBase(session),
token: _tokenController.text,
);
if (mounted) setState(() => _busy = false);
}
}