From b4dab2271bb85079c465274d5d1e2f5877d99e29 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 8 May 2026 16:44:32 +0700 Subject: [PATCH] Authenticate reviews with Telegram init data --- lib/api/mapflow_api.dart | 47 ++++++++++++++++++++++++++- lib/auth/telegram_init_data.dart | 2 ++ lib/auth/telegram_init_data_stub.dart | 1 + lib/auth/telegram_init_data_web.dart | 6 ++++ lib/models/place_models.dart | 32 ++++++++++++++++++ lib/screens/mapflow_shell.dart | 28 +++++++++++++--- lib/state/place_controller.dart | 15 +++++++++ 7 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 lib/auth/telegram_init_data.dart create mode 100644 lib/auth/telegram_init_data_stub.dart create mode 100644 lib/auth/telegram_init_data_web.dart diff --git a/lib/api/mapflow_api.dart b/lib/api/mapflow_api.dart index 6fa6f21..0ff224b 100644 --- a/lib/api/mapflow_api.dart +++ b/lib/api/mapflow_api.dart @@ -3,21 +3,58 @@ 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 '../models/place_models.dart'; class MapflowApi { MapflowApi({ http.Client? client, + String? telegramInitData, String endpoint = const String.fromEnvironment( 'API_BASE_URL', defaultValue: '/graphql', ), }) : _client = client ?? http.Client(), + _telegramInitData = telegramInitData ?? telegram_auth.telegramInitData(), _endpoint = Uri.base.resolve(endpoint); final http.Client _client; + final String _telegramInitData; final Uri _endpoint; + bool get hasTelegramAuth => _telegramInitData.isNotEmpty; + + Future authenticateTelegram() async { + if (_telegramInitData.isEmpty) { + return null; + } + + final data = await _graphql( + ''' + mutation AuthenticateTelegram(\$input: AuthenticateTelegramInput!) { + authenticateTelegram(input: \$input) { + user { + id + telegramId + username + firstName + lastName + photoUrl + languageCode + } + } + } + ''', + variables: { + 'input': {'initData': _telegramInitData}, + }, + ); + + final payload = data['authenticateTelegram'] as Map; + final user = payload['user'] as Map; + return AppUser.fromJson(user); + } + Future> fetchPlaces() async { final data = await _graphql(''' query Places { @@ -61,6 +98,10 @@ class MapflowApi { required int durationSeconds, required String audioObjectKey, }) async { + if (_telegramInitData.isEmpty) { + throw StateError('Telegram authorization is required.'); + } + await _graphql( ''' mutation CreateVoiceExperience(\$input: CreateVoiceExperienceInput!) { @@ -88,7 +129,11 @@ class MapflowApi { }) async { final response = await _client.post( _endpoint, - headers: const {'content-type': 'application/json'}, + headers: { + 'content-type': 'application/json', + if (_telegramInitData.isNotEmpty) + 'x-telegram-init-data': _telegramInitData, + }, body: jsonEncode({'query': query, 'variables': variables ?? {}}), ); diff --git a/lib/auth/telegram_init_data.dart b/lib/auth/telegram_init_data.dart new file mode 100644 index 0000000..da1bdf6 --- /dev/null +++ b/lib/auth/telegram_init_data.dart @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..f97bba5 --- /dev/null +++ b/lib/auth/telegram_init_data_stub.dart @@ -0,0 +1 @@ +String telegramInitData() => ''; diff --git a/lib/auth/telegram_init_data_web.dart b/lib/auth/telegram_init_data_web.dart new file mode 100644 index 0000000..33f8c0b --- /dev/null +++ b/lib/auth/telegram_init_data_web.dart @@ -0,0 +1,6 @@ +import 'dart:js_interop'; + +@JS('window.Telegram.WebApp.initData') +external JSString? get _telegramInitData; + +String telegramInitData() => _telegramInitData?.toDart ?? ''; diff --git a/lib/models/place_models.dart b/lib/models/place_models.dart index b7c9d69..6726c9e 100644 --- a/lib/models/place_models.dart +++ b/lib/models/place_models.dart @@ -1,6 +1,38 @@ import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; +class AppUser { + const AppUser({ + required this.id, + required this.telegramId, + required this.username, + required this.firstName, + required this.lastName, + required this.photoUrl, + required this.languageCode, + }); + + factory AppUser.fromJson(Map json) { + return AppUser( + id: json['id'] as String, + telegramId: json['telegramId'] as String, + username: json['username'] as String?, + firstName: json['firstName'] as String?, + lastName: json['lastName'] as String?, + photoUrl: json['photoUrl'] as String?, + languageCode: json['languageCode'] as String?, + ); + } + + final String id; + final String telegramId; + final String? username; + final String? firstName; + final String? lastName; + final String? photoUrl; + final String? languageCode; +} + enum PlaceTrait { calm, dynamic, diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index a844b10..e09d803 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -110,7 +110,10 @@ class _MapContent extends ConsumerWidget { Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, - builder: (_) => AddExperienceFlow(coordinate: coordinate ?? _center), + builder: (_) => AddExperienceFlow( + coordinate: coordinate ?? _center, + hasTelegramAuth: state.hasTelegramAuth, + ), ), ); } @@ -400,9 +403,14 @@ class _PlacePhotoCard extends StatelessWidget { } class AddExperienceFlow extends ConsumerStatefulWidget { - const AddExperienceFlow({super.key, required this.coordinate}); + const AddExperienceFlow({ + super.key, + required this.coordinate, + required this.hasTelegramAuth, + }); final LatLng coordinate; + final bool hasTelegramAuth; @override ConsumerState createState() => _AddExperienceFlowState(); @@ -474,12 +482,13 @@ class _AddExperienceFlowState extends ConsumerState { ), _ => _VoiceStep( placeName: _placeNameController.text, + hasTelegramAuth: widget.hasTelegramAuth, seconds: _seconds, minimumSeconds: _minimumVoiceSeconds, time: time, isRecording: _recording, isSubmitting: _submitting, - canContinue: _seconds >= _minimumVoiceSeconds, + canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds, onToggleRecording: _toggleRecording, onNext: () async { setState(() => _submitting = true); @@ -632,6 +641,7 @@ class _PlaceStep extends StatelessWidget { class _VoiceStep extends StatelessWidget { const _VoiceStep({ required this.placeName, + required this.hasTelegramAuth, required this.seconds, required this.minimumSeconds, required this.time, @@ -643,6 +653,7 @@ class _VoiceStep extends StatelessWidget { }); final String placeName; + final bool hasTelegramAuth; final int seconds; final int minimumSeconds; final String time; @@ -666,13 +677,20 @@ class _VoiceStep extends StatelessWidget { ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ), const SizedBox(height: 8), - Text('Минимум $minimumSeconds секунд', textAlign: TextAlign.center), + Text( + hasTelegramAuth + ? 'Минимум $minimumSeconds секунд' + : 'Открой через Telegram', + textAlign: TextAlign.center, + ), const SizedBox(height: 26), SizedBox( width: 132, height: 132, child: FilledButton( - onPressed: isSubmitting ? null : onToggleRecording, + onPressed: isSubmitting || !hasTelegramAuth + ? null + : onToggleRecording, style: FilledButton.styleFrom(shape: const CircleBorder()), child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54), ), diff --git a/lib/state/place_controller.dart b/lib/state/place_controller.dart index 33941a1..45f3983 100644 --- a/lib/state/place_controller.dart +++ b/lib/state/place_controller.dart @@ -12,12 +12,16 @@ class PlaceState { required this.selectedTrait, required this.places, required this.selectedPlaceId, + required this.currentUser, + required this.hasTelegramAuth, required this.reviewDraft, }); final PlaceTrait selectedTrait; final List places; final String? selectedPlaceId; + final AppUser? currentUser; + final bool hasTelegramAuth; final VoiceReviewDraft reviewDraft; List get recommendations { @@ -43,12 +47,16 @@ class PlaceState { PlaceTrait? selectedTrait, List? places, String? selectedPlaceId, + AppUser? currentUser, + bool? hasTelegramAuth, VoiceReviewDraft? reviewDraft, }) { return PlaceState( selectedTrait: selectedTrait ?? this.selectedTrait, places: places ?? this.places, selectedPlaceId: selectedPlaceId ?? this.selectedPlaceId, + currentUser: currentUser ?? this.currentUser, + hasTelegramAuth: hasTelegramAuth ?? this.hasTelegramAuth, reviewDraft: reviewDraft ?? this.reviewDraft, ); } @@ -59,11 +67,14 @@ class PlaceController extends AsyncNotifier { @override Future build() async { + final currentUser = await _api.authenticateTelegram(); final places = await _api.fetchPlaces(); return PlaceState( selectedTrait: PlaceTrait.calm, places: places, selectedPlaceId: places.isEmpty ? null : places.first.id, + currentUser: currentUser, + hasTelegramAuth: _api.hasTelegramAuth, reviewDraft: const VoiceReviewDraft( placeName: '', duration: Duration.zero, @@ -112,6 +123,10 @@ class PlaceController extends AsyncNotifier { Future publishReview({LatLng? coordinate}) async { final value = state.requireValue; + if (!value.hasTelegramAuth) { + throw StateError('Открой через Telegram, чтобы оставить голос.'); + } + final draft = value.reviewDraft; final placeName = draft.placeName.trim().isEmpty ? 'Место на карте'