Replace Telegram widget with bot login
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m22s
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m22s
This commit is contained in:
@@ -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 ?? {}}),
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,6 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('MapFlow'), findsOneWidget);
|
||||
expect(find.text('Telegram'), findsOneWidget);
|
||||
expect(find.text('Войти через Telegram'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user