332 lines
11 KiB
Dart
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);
|
|
}
|
|
}
|