feat(proj): init
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user