From 5f33a5e8800a2dd6563259eefa2ce73f4fe24bfe Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 8 May 2026 19:32:01 +0700 Subject: [PATCH] Replace Telegram widget with bot login --- lib/api/mapflow_api.dart | 70 ++++++++++++++++++- lib/auth/telegram_login_button.dart | 28 +++++++- lib/auth/telegram_login_button_stub.dart | 12 ---- lib/auth/telegram_login_button_web.dart | 77 -------------------- lib/auth/telegram_session_stub.dart | 16 +++++ lib/auth/telegram_session_web.dart | 33 +++++++++ lib/models/place_models.dart | 44 ++++++++++++ lib/screens/mapflow_shell.dart | 89 +++++++++++++++++++++++- test/widget_test.dart | 2 +- 9 files changed, 276 insertions(+), 95 deletions(-) delete mode 100644 lib/auth/telegram_login_button_stub.dart delete mode 100644 lib/auth/telegram_login_button_web.dart diff --git a/lib/api/mapflow_api.dart b/lib/api/mapflow_api.dart index 6d403b5..60cf676 100644 --- a/lib/api/mapflow_api.dart +++ b/lib/api/mapflow_api.dart @@ -11,6 +11,7 @@ class MapflowApi { http.Client? client, String? telegramInitData, String? telegramLoginData, + String? mapflowSessionToken, String endpoint = const String.fromEnvironment( 'API_BASE_URL', defaultValue: '/graphql', @@ -19,21 +20,43 @@ class MapflowApi { _telegramInitData = telegramInitData ?? telegram_auth.telegramInitData(), _telegramLoginData = telegramLoginData ?? telegram_auth.telegramLoginData(), + _mapflowSessionToken = + mapflowSessionToken ?? telegram_auth.mapflowSessionToken(), _endpoint = Uri.base.resolve(endpoint); final http.Client _client; final String _telegramInitData; final String _telegramLoginData; + final String _mapflowSessionToken; final Uri _endpoint; bool get hasTelegramAuth => - _telegramInitData.isNotEmpty || _telegramLoginData.isNotEmpty; + _telegramInitData.isNotEmpty || + _telegramLoginData.isNotEmpty || + _mapflowSessionToken.isNotEmpty; Future authenticateTelegram() async { if (!hasTelegramAuth) { return null; } + if (_mapflowSessionToken.isNotEmpty) { + final data = await _graphql(''' + query Me { + me { + id + telegramId + username + firstName + lastName + photoUrl + languageCode + } + } + '''); + return AppUser.fromJson(data['me'] as Map); + } + if (_telegramLoginData.isNotEmpty) { final loginData = jsonDecode(_telegramLoginData) as Map; final data = await _graphql( @@ -88,6 +111,49 @@ class MapflowApi { return AppUser.fromJson(user); } + Future startTelegramBotLogin() async { + final data = await _graphql(''' + mutation StartTelegramBotLogin { + startTelegramBotLogin { + token + botUrl + expiresAt + } + } + '''); + return TelegramBotLogin.fromJson( + data['startTelegramBotLogin'] as Map, + ); + } + + Future fetchTelegramBotLoginStatus( + String token, + ) async { + final data = await _graphql( + ''' + query TelegramBotLoginStatus(\$token: String!) { + telegramBotLoginStatus(token: \$token) { + status + sessionToken + user { + id + telegramId + username + firstName + lastName + photoUrl + languageCode + } + } + } + ''', + variables: {'token': token}, + ); + return TelegramBotLoginStatus.fromJson( + data['telegramBotLoginStatus'] as Map, + ); + } + Future> fetchPlaces() async { final data = await _graphql(''' query Places { @@ -168,6 +234,8 @@ class MapflowApi { 'x-telegram-init-data': _telegramInitData, if (_telegramLoginData.isNotEmpty) 'x-telegram-login-data': _telegramLoginData, + if (_mapflowSessionToken.isNotEmpty) + 'x-mapflow-session-token': _mapflowSessionToken, }, body: jsonEncode({'query': query, 'variables': variables ?? {}}), ); diff --git a/lib/auth/telegram_login_button.dart b/lib/auth/telegram_login_button.dart index ffa35ea..470bb39 100644 --- a/lib/auth/telegram_login_button.dart +++ b/lib/auth/telegram_login_button.dart @@ -1,2 +1,26 @@ -export 'telegram_login_button_stub.dart' - if (dart.library.html) 'telegram_login_button_web.dart'; +import 'package:flutter/material.dart'; + +class TelegramLoginButton extends StatelessWidget { + const TelegramLoginButton({ + required this.onPressed, + required this.loading, + super.key, + }); + + final VoidCallback? onPressed; + final bool loading; + + @override + Widget build(BuildContext context) { + return FilledButton.icon( + onPressed: loading ? null : onPressed, + icon: loading + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.telegram), + label: const Text('Войти через Telegram'), + ); + } +} diff --git a/lib/auth/telegram_login_button_stub.dart b/lib/auth/telegram_login_button_stub.dart deleted file mode 100644 index f150846..0000000 --- a/lib/auth/telegram_login_button_stub.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/material.dart'; - -class TelegramLoginButton extends StatelessWidget { - const TelegramLoginButton({required this.onAuthenticated, super.key}); - - final VoidCallback onAuthenticated; - - @override - Widget build(BuildContext context) { - return const FilledButton(onPressed: null, child: Text('Telegram')); - } -} diff --git a/lib/auth/telegram_login_button_web.dart b/lib/auth/telegram_login_button_web.dart deleted file mode 100644 index dd47d78..0000000 --- a/lib/auth/telegram_login_button_web.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:js_interop'; -import 'dart:js_interop_unsafe'; -import 'dart:ui_web' as ui_web; - -import 'package:flutter/widgets.dart'; -import 'package:web/web.dart' as web; - -import 'telegram_session_web.dart'; - -const _telegramBotUsername = String.fromEnvironment( - 'TELEGRAM_BOT_USERNAME', - defaultValue: 'carfteebot', -); - -@JS('window') -external JSObject get _window; - -class TelegramLoginButton extends StatefulWidget { - const TelegramLoginButton({required this.onAuthenticated, super.key}); - - final VoidCallback onAuthenticated; - - @override - State createState() => _TelegramLoginButtonState(); -} - -class _TelegramLoginButtonState extends State { - late final String _callbackName = - 'mapflowTelegramAuth${DateTime.now().microsecondsSinceEpoch}'; - late final String _viewType = 'mapflow-telegram-login-$_callbackName'; - - @override - void initState() { - super.initState(); - final callback = ((JSAny? user) { - saveTelegramLoginData(user); - if (mounted) { - widget.onAuthenticated(); - } - web.window.location.reload(); - }).toJS; - _window.setProperty(_callbackName.toJS, callback); - ui_web.platformViewRegistry.registerViewFactory(_viewType, _buildElement); - } - - web.HTMLElement _buildElement(int viewId) { - final container = web.HTMLDivElement() - ..style.width = '100%' - ..style.height = '48px' - ..style.display = 'flex' - ..style.justifyContent = 'center' - ..style.alignItems = 'center'; - - final script = web.HTMLScriptElement() - ..src = 'https://telegram.org/js/telegram-widget.js?22' - ..async = true; - - script - ..setAttribute('data-telegram-login', _telegramBotUsername) - ..setAttribute('data-size', 'large') - ..setAttribute('data-radius', '8') - ..setAttribute('data-userpic', 'false') - ..setAttribute('data-onauth', '$_callbackName(user)'); - - container.append(script); - return container; - } - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 48, - width: 240, - child: HtmlElementView(viewType: _viewType), - ); - } -} diff --git a/lib/auth/telegram_session_stub.dart b/lib/auth/telegram_session_stub.dart index d25e1f5..c82a380 100644 --- a/lib/auth/telegram_session_stub.dart +++ b/lib/auth/telegram_session_stub.dart @@ -1,3 +1,19 @@ String telegramInitData() => ''; String telegramLoginData() => ''; + +String mapflowSessionToken() => ''; + +String pendingTelegramLoginToken() => ''; + +String telegramLoginTokenFromUrl() => ''; + +void saveMapflowSessionToken(String token) {} + +void savePendingTelegramLoginToken(String token) {} + +void clearPendingTelegramLoginToken() {} + +void openExternalUrl(String url) {} + +void reloadApp() {} diff --git a/lib/auth/telegram_session_web.dart b/lib/auth/telegram_session_web.dart index 85c3f11..15a27e9 100644 --- a/lib/auth/telegram_session_web.dart +++ b/lib/auth/telegram_session_web.dart @@ -3,6 +3,8 @@ import 'dart:js_interop'; import 'package:web/web.dart' as web; const telegramLoginStorageKey = 'mapflow.telegramLoginData'; +const mapflowSessionStorageKey = 'mapflow.sessionToken'; +const pendingTelegramLoginStorageKey = 'mapflow.pendingTelegramLoginToken'; @JS('window.Telegram.WebApp.initData') external JSString? get _telegramInitData; @@ -15,7 +17,38 @@ String telegramInitData() => _telegramInitData?.toDart ?? ''; String telegramLoginData() => web.window.localStorage.getItem(telegramLoginStorageKey) ?? ''; +String mapflowSessionToken() => + web.window.localStorage.getItem(mapflowSessionStorageKey) ?? ''; + +String pendingTelegramLoginToken() => + web.window.localStorage.getItem(pendingTelegramLoginStorageKey) ?? ''; + +String telegramLoginTokenFromUrl() { + final uri = Uri.parse(web.window.location.href); + return uri.queryParameters['telegram_login'] ?? ''; +} + void saveTelegramLoginData(JSAny? user) { final encoded = _jsonStringify(user).toDart; web.window.localStorage.setItem(telegramLoginStorageKey, encoded); } + +void saveMapflowSessionToken(String token) { + web.window.localStorage.setItem(mapflowSessionStorageKey, token); +} + +void savePendingTelegramLoginToken(String token) { + web.window.localStorage.setItem(pendingTelegramLoginStorageKey, token); +} + +void clearPendingTelegramLoginToken() { + web.window.localStorage.removeItem(pendingTelegramLoginStorageKey); +} + +void openExternalUrl(String url) { + web.window.open(url, '_blank', 'noopener,noreferrer'); +} + +void reloadApp() { + web.window.location.assign(web.window.location.origin); +} diff --git a/lib/models/place_models.dart b/lib/models/place_models.dart index 6726c9e..0e23db9 100644 --- a/lib/models/place_models.dart +++ b/lib/models/place_models.dart @@ -130,3 +130,47 @@ class VoiceReviewDraft { ); } } + +class TelegramBotLogin { + const TelegramBotLogin({ + required this.token, + required this.botUrl, + required this.expiresAt, + }); + + factory TelegramBotLogin.fromJson(Map json) { + return TelegramBotLogin( + token: json['token'] as String, + botUrl: json['botUrl'] as String, + expiresAt: DateTime.parse(json['expiresAt'] as String), + ); + } + + final String token; + final String botUrl; + final DateTime expiresAt; +} + +class TelegramBotLoginStatus { + const TelegramBotLoginStatus({ + required this.status, + required this.sessionToken, + required this.user, + }); + + factory TelegramBotLoginStatus.fromJson(Map json) { + final user = json['user']; + return TelegramBotLoginStatus( + status: json['status'] as String, + sessionToken: json['sessionToken'] as String?, + user: user is Map ? AppUser.fromJson(user) : null, + ); + } + + final String status; + final String? sessionToken; + final AppUser? user; + + bool get isConfirmed => status == 'CONFIRMED' && sessionToken != null; + bool get isExpired => status == 'EXPIRED'; +} diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index e1f815e..5f9276f 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -5,7 +5,9 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; +import '../api/mapflow_api.dart'; import '../auth/telegram_login_button.dart'; +import '../auth/telegram_session.dart' as telegram_session; import '../models/place_models.dart'; import '../state/place_controller.dart'; @@ -179,11 +181,86 @@ class _UserAvatar extends StatelessWidget { } } -class _TelegramLoginScreen extends StatelessWidget { +class _TelegramLoginScreen extends StatefulWidget { const _TelegramLoginScreen({required this.onAuthenticated}); final VoidCallback onAuthenticated; + @override + State<_TelegramLoginScreen> createState() => _TelegramLoginScreenState(); +} + +class _TelegramLoginScreenState extends State<_TelegramLoginScreen> { + final _api = MapflowApi(); + Timer? _pollTimer; + var _loading = false; + var _message = ''; + + @override + void initState() { + super.initState(); + final urlToken = telegram_session.telegramLoginTokenFromUrl(); + final pendingToken = urlToken.isNotEmpty + ? urlToken + : telegram_session.pendingTelegramLoginToken(); + if (pendingToken.isNotEmpty) { + telegram_session.savePendingTelegramLoginToken(pendingToken); + _pollLogin(pendingToken); + _pollTimer = Timer.periodic( + const Duration(seconds: 2), + (_) => _pollLogin(pendingToken), + ); + } + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + Future _startLogin() async { + setState(() { + _loading = true; + _message = ''; + }); + final login = await _api.startTelegramBotLogin(); + telegram_session.savePendingTelegramLoginToken(login.token); + telegram_session.openExternalUrl(login.botUrl); + _pollTimer?.cancel(); + _pollTimer = Timer.periodic( + const Duration(seconds: 2), + (_) => _pollLogin(login.token), + ); + setState(() { + _loading = false; + _message = 'Подтверди вход в боте.'; + }); + } + + Future _pollLogin(String token) async { + final status = await _api.fetchTelegramBotLoginStatus(token); + if (status.isExpired) { + _pollTimer?.cancel(); + telegram_session.clearPendingTelegramLoginToken(); + if (!mounted) { + return; + } + setState(() => _message = 'Ссылка устарела. Запусти вход заново.'); + return; + } + + if (!status.isConfirmed) { + return; + } + + _pollTimer?.cancel(); + telegram_session.saveMapflowSessionToken(status.sessionToken!); + telegram_session.clearPendingTelegramLoginToken(); + widget.onAuthenticated(); + telegram_session.reloadApp(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -202,7 +279,15 @@ class _TelegramLoginScreen extends StatelessWidget { ), ), const SizedBox(height: 24), - TelegramLoginButton(onAuthenticated: onAuthenticated), + TelegramLoginButton(onPressed: _startLogin, loading: _loading), + if (_message.isNotEmpty) ...[ + const SizedBox(height: 14), + Text( + _message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], ], ), ), diff --git a/test/widget_test.dart b/test/widget_test.dart index e39d027..bd9d5df 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -9,6 +9,6 @@ void main() { await tester.pump(); expect(find.text('MapFlow'), findsOneWidget); - expect(find.text('Telegram'), findsOneWidget); + expect(find.text('Войти через Telegram'), findsOneWidget); }); }