diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index 0fb8ec9..927cf60 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -43,7 +43,7 @@ jobs: --tag "$IMAGE" \ --build-arg MAPBOX_ACCESS_TOKEN="${{ secrets.MAPBOX_ACCESS_TOKEN }}" \ --build-arg MAPBOX_STYLE="mapbox/streets-v12" \ - --build-arg TELEGRAM_BOT_URL="https://t.me/carfteebot" \ + --build-arg TELEGRAM_BOT_USERNAME="carfteebot" \ . - name: Skip stale deployment diff --git a/Dockerfile b/Dockerfile index b4b7af7..f9b4581 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,14 +2,14 @@ FROM ghcr.io/cirruslabs/flutter:stable AS build WORKDIR /app ARG MAPBOX_ACCESS_TOKEN="" ARG MAPBOX_STYLE="mapbox/streets-v12" -ARG TELEGRAM_BOT_URL="https://t.me/carfteebot" +ARG TELEGRAM_BOT_USERNAME="carfteebot" COPY pubspec.* ./ RUN flutter pub get COPY . . RUN flutter build web --release \ --dart-define=MAPBOX_ACCESS_TOKEN="$MAPBOX_ACCESS_TOKEN" \ --dart-define=MAPBOX_STYLE="$MAPBOX_STYLE" \ - --dart-define=TELEGRAM_BOT_URL="$TELEGRAM_BOT_URL" + --dart-define=TELEGRAM_BOT_USERNAME="$TELEGRAM_BOT_USERNAME" FROM nginx:1.27-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/lib/api/mapflow_api.dart b/lib/api/mapflow_api.dart index 0ff224b..6d403b5 100644 --- a/lib/api/mapflow_api.dart +++ b/lib/api/mapflow_api.dart @@ -3,32 +3,65 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; -import '../auth/telegram_init_data.dart' as telegram_auth; +import '../auth/telegram_session.dart' as telegram_auth; import '../models/place_models.dart'; class MapflowApi { MapflowApi({ http.Client? client, String? telegramInitData, + String? telegramLoginData, String endpoint = const String.fromEnvironment( 'API_BASE_URL', defaultValue: '/graphql', ), }) : _client = client ?? http.Client(), _telegramInitData = telegramInitData ?? telegram_auth.telegramInitData(), + _telegramLoginData = + telegramLoginData ?? telegram_auth.telegramLoginData(), _endpoint = Uri.base.resolve(endpoint); final http.Client _client; final String _telegramInitData; + final String _telegramLoginData; final Uri _endpoint; - bool get hasTelegramAuth => _telegramInitData.isNotEmpty; + bool get hasTelegramAuth => + _telegramInitData.isNotEmpty || _telegramLoginData.isNotEmpty; Future authenticateTelegram() async { - if (_telegramInitData.isEmpty) { + if (!hasTelegramAuth) { return null; } + if (_telegramLoginData.isNotEmpty) { + final loginData = jsonDecode(_telegramLoginData) as Map; + final data = await _graphql( + ''' + mutation AuthenticateTelegramLogin( + \$input: AuthenticateTelegramLoginInput! + ) { + authenticateTelegramLogin(input: \$input) { + user { + id + telegramId + username + firstName + lastName + photoUrl + languageCode + } + } + } + ''', + variables: {'input': loginData}, + ); + + final payload = data['authenticateTelegramLogin'] as Map; + final user = payload['user'] as Map; + return AppUser.fromJson(user); + } + final data = await _graphql( ''' mutation AuthenticateTelegram(\$input: AuthenticateTelegramInput!) { @@ -98,7 +131,7 @@ class MapflowApi { required int durationSeconds, required String audioObjectKey, }) async { - if (_telegramInitData.isEmpty) { + if (!hasTelegramAuth) { throw StateError('Telegram authorization is required.'); } @@ -133,6 +166,8 @@ class MapflowApi { 'content-type': 'application/json', if (_telegramInitData.isNotEmpty) 'x-telegram-init-data': _telegramInitData, + if (_telegramLoginData.isNotEmpty) + 'x-telegram-login-data': _telegramLoginData, }, body: jsonEncode({'query': query, 'variables': variables ?? {}}), ); diff --git a/lib/auth/telegram_init_data.dart b/lib/auth/telegram_init_data.dart deleted file mode 100644 index da1bdf6..0000000 --- a/lib/auth/telegram_init_data.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'telegram_init_data_stub.dart' - if (dart.library.js_interop) 'telegram_init_data_web.dart'; diff --git a/lib/auth/telegram_init_data_stub.dart b/lib/auth/telegram_init_data_stub.dart deleted file mode 100644 index f97bba5..0000000 --- a/lib/auth/telegram_init_data_stub.dart +++ /dev/null @@ -1 +0,0 @@ -String telegramInitData() => ''; diff --git a/lib/auth/telegram_init_data_web.dart b/lib/auth/telegram_init_data_web.dart deleted file mode 100644 index 33f8c0b..0000000 --- a/lib/auth/telegram_init_data_web.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'dart:js_interop'; - -@JS('window.Telegram.WebApp.initData') -external JSString? get _telegramInitData; - -String telegramInitData() => _telegramInitData?.toDart ?? ''; diff --git a/lib/auth/telegram_launcher.dart b/lib/auth/telegram_launcher.dart deleted file mode 100644 index d3c0f07..0000000 --- a/lib/auth/telegram_launcher.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'telegram_launcher_stub.dart' - if (dart.library.js_interop) 'telegram_launcher_web.dart'; diff --git a/lib/auth/telegram_launcher_stub.dart b/lib/auth/telegram_launcher_stub.dart deleted file mode 100644 index 87987db..0000000 --- a/lib/auth/telegram_launcher_stub.dart +++ /dev/null @@ -1 +0,0 @@ -void openTelegramUrl(String url) {} diff --git a/lib/auth/telegram_launcher_web.dart b/lib/auth/telegram_launcher_web.dart deleted file mode 100644 index 6ad6cac..0000000 --- a/lib/auth/telegram_launcher_web.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'dart:js_interop'; - -@JS('window.open') -external JSAny? _open(JSString url, JSString target, JSString features); - -void openTelegramUrl(String url) { - _open(url.toJS, '_blank'.toJS, 'noopener,noreferrer'.toJS); -} diff --git a/lib/auth/telegram_login_button.dart b/lib/auth/telegram_login_button.dart new file mode 100644 index 0000000..ffa35ea --- /dev/null +++ b/lib/auth/telegram_login_button.dart @@ -0,0 +1,2 @@ +export 'telegram_login_button_stub.dart' + if (dart.library.html) 'telegram_login_button_web.dart'; diff --git a/lib/auth/telegram_login_button_stub.dart b/lib/auth/telegram_login_button_stub.dart new file mode 100644 index 0000000..f150846 --- /dev/null +++ b/lib/auth/telegram_login_button_stub.dart @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..d5ee161 --- /dev/null +++ b/lib/auth/telegram_login_button_web.dart @@ -0,0 +1,76 @@ +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(); + } + }).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.dart b/lib/auth/telegram_session.dart new file mode 100644 index 0000000..fb27b63 --- /dev/null +++ b/lib/auth/telegram_session.dart @@ -0,0 +1,2 @@ +export 'telegram_session_stub.dart' + if (dart.library.html) 'telegram_session_web.dart'; diff --git a/lib/auth/telegram_session_stub.dart b/lib/auth/telegram_session_stub.dart new file mode 100644 index 0000000..d25e1f5 --- /dev/null +++ b/lib/auth/telegram_session_stub.dart @@ -0,0 +1,3 @@ +String telegramInitData() => ''; + +String telegramLoginData() => ''; diff --git a/lib/auth/telegram_session_web.dart b/lib/auth/telegram_session_web.dart new file mode 100644 index 0000000..85c3f11 --- /dev/null +++ b/lib/auth/telegram_session_web.dart @@ -0,0 +1,21 @@ +import 'dart:js_interop'; + +import 'package:web/web.dart' as web; + +const telegramLoginStorageKey = 'mapflow.telegramLoginData'; + +@JS('window.Telegram.WebApp.initData') +external JSString? get _telegramInitData; + +@JS('JSON.stringify') +external JSString _jsonStringify(JSAny? value); + +String telegramInitData() => _telegramInitData?.toDart ?? ''; + +String telegramLoginData() => + web.window.localStorage.getItem(telegramLoginStorageKey) ?? ''; + +void saveTelegramLoginData(JSAny? user) { + final encoded = _jsonStringify(user).toDart; + web.window.localStorage.setItem(telegramLoginStorageKey, encoded); +} diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index 73ad0d2..0082aeb 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -5,7 +5,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; -import '../auth/telegram_launcher.dart'; +import '../auth/telegram_login_button.dart'; import '../models/place_models.dart'; import '../state/place_controller.dart'; @@ -14,10 +14,6 @@ const _mapboxStyle = String.fromEnvironment( 'MAPBOX_STYLE', defaultValue: 'mapbox/streets-v12', ); -const _telegramBotUrl = String.fromEnvironment( - 'TELEGRAM_BOT_URL', - defaultValue: 'https://t.me/carfteebot', -); class MapflowShell extends ConsumerWidget { const MapflowShell({super.key}); @@ -29,9 +25,8 @@ class MapflowShell extends ConsumerWidget { return asyncState.when( data: (state) => state.hasTelegramAuth ? _MapContent(state: state) - : _TelegramAuthGate( - onOpenTelegram: () => openTelegramUrl(_telegramBotUrl), - onRetry: () => ref.invalidate(placeControllerProvider), + : _TelegramLoginScreen( + onAuthenticated: () => ref.invalidate(placeControllerProvider), ), loading: () => const _MapLoading(), error: (error, _) => _MapError(message: error.toString()), @@ -129,66 +124,34 @@ class _MapContent extends ConsumerWidget { } } -class _TelegramAuthGate extends StatelessWidget { - const _TelegramAuthGate({ - required this.onOpenTelegram, - required this.onRetry, - }); +class _TelegramLoginScreen extends StatelessWidget { + const _TelegramLoginScreen({required this.onAuthenticated}); - final VoidCallback onOpenTelegram; - final VoidCallback onRetry; + final VoidCallback onAuthenticated; @override Widget build(BuildContext context) { return Scaffold( - body: Stack( - children: [ - FlutterMap( - options: const MapOptions( - initialCenter: LatLng(10.7718, 106.6982), - initialZoom: 14.2, - ), - children: [const _BaseMapTileLayer(), const _MapAttribution()], - ), - ColoredBox(color: Colors.black.withValues(alpha: 0.18)), - SafeArea( - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - width: double.infinity, - margin: const EdgeInsets.all(12), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFFFFFBF5), - borderRadius: BorderRadius.circular(8), + body: SafeArea( + child: Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'MapFlow', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: 0, + ), ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Открой через Telegram, чтобы продолжить.', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - letterSpacing: 0, - ), - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: onOpenTelegram, - icon: const Icon(Icons.telegram), - label: const Text('Telegram'), - ), - TextButton( - onPressed: onRetry, - child: const Text('Обновить'), - ), - ], - ), - ), + const SizedBox(height: 24), + TelegramLoginButton(onAuthenticated: onAuthenticated), + ], ), ), - ], + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index f125445..d1c1291 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -713,7 +713,7 @@ packages: source: hosted version: "1.2.1" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/pubspec.yaml b/pubspec.yaml index ec8b014..282fe5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: flutter_map: ^8.3.0 latlong2: ^0.9.1 http: ^1.6.0 + web: ^1.1.1 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index dc0b224..e39d027 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,14 +1,14 @@ -import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mapflow/main.dart'; void main() { - testWidgets('renders the mapflow shell', (tester) async { + testWidgets('renders Telegram login before authorization', (tester) async { await tester.pumpWidget(const ProviderScope(child: MapflowApp())); await tester.pump(); - expect(find.byType(FlutterMap), findsOneWidget); + expect(find.text('MapFlow'), findsOneWidget); + expect(find.text('Telegram'), findsOneWidget); }); }