Replace Telegram widget with bot login
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m22s

This commit is contained in:
Ruslan Bakiev
2026-05-08 19:32:01 +07:00
parent cd62a0a428
commit 5f33a5e880
9 changed files with 276 additions and 95 deletions

View File

@@ -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<AppUser?> 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<String, dynamic>);
}
if (_telegramLoginData.isNotEmpty) {
final loginData = jsonDecode(_telegramLoginData) as Map<String, dynamic>;
final data = await _graphql(
@@ -88,6 +111,49 @@ class MapflowApi {
return AppUser.fromJson(user);
}
Future<TelegramBotLogin> startTelegramBotLogin() async {
final data = await _graphql('''
mutation StartTelegramBotLogin {
startTelegramBotLogin {
token
botUrl
expiresAt
}
}
''');
return TelegramBotLogin.fromJson(
data['startTelegramBotLogin'] as Map<String, dynamic>,
);
}
Future<TelegramBotLoginStatus> 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<String, dynamic>,
);
}
Future<List<PlaceRecommendation>> 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 ?? {}}),
);

View File

@@ -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'),
);
}
}

View File

@@ -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'));
}
}

View File

@@ -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<TelegramLoginButton> createState() => _TelegramLoginButtonState();
}
class _TelegramLoginButtonState extends State<TelegramLoginButton> {
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),
);
}
}

View File

@@ -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() {}

View File

@@ -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);
}

View File

@@ -130,3 +130,47 @@ class VoiceReviewDraft {
);
}
}
class TelegramBotLogin {
const TelegramBotLogin({
required this.token,
required this.botUrl,
required this.expiresAt,
});
factory TelegramBotLogin.fromJson(Map<String, dynamic> 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<String, dynamic> json) {
final user = json['user'];
return TelegramBotLoginStatus(
status: json['status'] as String,
sessionToken: json['sessionToken'] as String?,
user: user is Map<String, dynamic> ? 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';
}

View File

@@ -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<void> _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<void> _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,
),
],
],
),
),

View File

@@ -9,6 +9,6 @@ void main() {
await tester.pump();
expect(find.text('MapFlow'), findsOneWidget);
expect(find.text('Telegram'), findsOneWidget);
expect(find.text('Войти через Telegram'), findsOneWidget);
});
}