Authenticate reviews with Telegram init data
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 3m1s

This commit is contained in:
Ruslan Bakiev
2026-05-08 16:44:32 +07:00
parent deba48185a
commit b4dab2271b
7 changed files with 125 additions and 6 deletions

View File

@@ -3,21 +3,58 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../auth/telegram_init_data.dart' as telegram_auth;
import '../models/place_models.dart'; import '../models/place_models.dart';
class MapflowApi { class MapflowApi {
MapflowApi({ MapflowApi({
http.Client? client, http.Client? client,
String? telegramInitData,
String endpoint = const String.fromEnvironment( String endpoint = const String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: '/graphql', defaultValue: '/graphql',
), ),
}) : _client = client ?? http.Client(), }) : _client = client ?? http.Client(),
_telegramInitData = telegramInitData ?? telegram_auth.telegramInitData(),
_endpoint = Uri.base.resolve(endpoint); _endpoint = Uri.base.resolve(endpoint);
final http.Client _client; final http.Client _client;
final String _telegramInitData;
final Uri _endpoint; final Uri _endpoint;
bool get hasTelegramAuth => _telegramInitData.isNotEmpty;
Future<AppUser?> 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<String, dynamic>;
final user = payload['user'] as Map<String, dynamic>;
return AppUser.fromJson(user);
}
Future<List<PlaceRecommendation>> fetchPlaces() async { Future<List<PlaceRecommendation>> fetchPlaces() async {
final data = await _graphql(''' final data = await _graphql('''
query Places { query Places {
@@ -61,6 +98,10 @@ class MapflowApi {
required int durationSeconds, required int durationSeconds,
required String audioObjectKey, required String audioObjectKey,
}) async { }) async {
if (_telegramInitData.isEmpty) {
throw StateError('Telegram authorization is required.');
}
await _graphql( await _graphql(
''' '''
mutation CreateVoiceExperience(\$input: CreateVoiceExperienceInput!) { mutation CreateVoiceExperience(\$input: CreateVoiceExperienceInput!) {
@@ -88,7 +129,11 @@ class MapflowApi {
}) async { }) async {
final response = await _client.post( final response = await _client.post(
_endpoint, _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 ?? {}}), body: jsonEncode({'query': query, 'variables': variables ?? {}}),
); );

View File

@@ -0,0 +1,2 @@
export 'telegram_init_data_stub.dart'
if (dart.library.js_interop) 'telegram_init_data_web.dart';

View File

@@ -0,0 +1 @@
String telegramInitData() => '';

View File

@@ -0,0 +1,6 @@
import 'dart:js_interop';
@JS('window.Telegram.WebApp.initData')
external JSString? get _telegramInitData;
String telegramInitData() => _telegramInitData?.toDart ?? '';

View File

@@ -1,6 +1,38 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:latlong2/latlong.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<String, dynamic> 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 { enum PlaceTrait {
calm, calm,
dynamic, dynamic,

View File

@@ -110,7 +110,10 @@ class _MapContent extends ConsumerWidget {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
fullscreenDialog: true, 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 { 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 LatLng coordinate;
final bool hasTelegramAuth;
@override @override
ConsumerState<AddExperienceFlow> createState() => _AddExperienceFlowState(); ConsumerState<AddExperienceFlow> createState() => _AddExperienceFlowState();
@@ -474,12 +482,13 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
), ),
_ => _VoiceStep( _ => _VoiceStep(
placeName: _placeNameController.text, placeName: _placeNameController.text,
hasTelegramAuth: widget.hasTelegramAuth,
seconds: _seconds, seconds: _seconds,
minimumSeconds: _minimumVoiceSeconds, minimumSeconds: _minimumVoiceSeconds,
time: time, time: time,
isRecording: _recording, isRecording: _recording,
isSubmitting: _submitting, isSubmitting: _submitting,
canContinue: _seconds >= _minimumVoiceSeconds, canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds,
onToggleRecording: _toggleRecording, onToggleRecording: _toggleRecording,
onNext: () async { onNext: () async {
setState(() => _submitting = true); setState(() => _submitting = true);
@@ -632,6 +641,7 @@ class _PlaceStep extends StatelessWidget {
class _VoiceStep extends StatelessWidget { class _VoiceStep extends StatelessWidget {
const _VoiceStep({ const _VoiceStep({
required this.placeName, required this.placeName,
required this.hasTelegramAuth,
required this.seconds, required this.seconds,
required this.minimumSeconds, required this.minimumSeconds,
required this.time, required this.time,
@@ -643,6 +653,7 @@ class _VoiceStep extends StatelessWidget {
}); });
final String placeName; final String placeName;
final bool hasTelegramAuth;
final int seconds; final int seconds;
final int minimumSeconds; final int minimumSeconds;
final String time; final String time;
@@ -666,13 +677,20 @@ class _VoiceStep extends StatelessWidget {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('Минимум $minimumSeconds секунд', textAlign: TextAlign.center), Text(
hasTelegramAuth
? 'Минимум $minimumSeconds секунд'
: 'Открой через Telegram',
textAlign: TextAlign.center,
),
const SizedBox(height: 26), const SizedBox(height: 26),
SizedBox( SizedBox(
width: 132, width: 132,
height: 132, height: 132,
child: FilledButton( child: FilledButton(
onPressed: isSubmitting ? null : onToggleRecording, onPressed: isSubmitting || !hasTelegramAuth
? null
: onToggleRecording,
style: FilledButton.styleFrom(shape: const CircleBorder()), style: FilledButton.styleFrom(shape: const CircleBorder()),
child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54), child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54),
), ),

View File

@@ -12,12 +12,16 @@ class PlaceState {
required this.selectedTrait, required this.selectedTrait,
required this.places, required this.places,
required this.selectedPlaceId, required this.selectedPlaceId,
required this.currentUser,
required this.hasTelegramAuth,
required this.reviewDraft, required this.reviewDraft,
}); });
final PlaceTrait selectedTrait; final PlaceTrait selectedTrait;
final List<PlaceRecommendation> places; final List<PlaceRecommendation> places;
final String? selectedPlaceId; final String? selectedPlaceId;
final AppUser? currentUser;
final bool hasTelegramAuth;
final VoiceReviewDraft reviewDraft; final VoiceReviewDraft reviewDraft;
List<PlaceRecommendation> get recommendations { List<PlaceRecommendation> get recommendations {
@@ -43,12 +47,16 @@ class PlaceState {
PlaceTrait? selectedTrait, PlaceTrait? selectedTrait,
List<PlaceRecommendation>? places, List<PlaceRecommendation>? places,
String? selectedPlaceId, String? selectedPlaceId,
AppUser? currentUser,
bool? hasTelegramAuth,
VoiceReviewDraft? reviewDraft, VoiceReviewDraft? reviewDraft,
}) { }) {
return PlaceState( return PlaceState(
selectedTrait: selectedTrait ?? this.selectedTrait, selectedTrait: selectedTrait ?? this.selectedTrait,
places: places ?? this.places, places: places ?? this.places,
selectedPlaceId: selectedPlaceId ?? this.selectedPlaceId, selectedPlaceId: selectedPlaceId ?? this.selectedPlaceId,
currentUser: currentUser ?? this.currentUser,
hasTelegramAuth: hasTelegramAuth ?? this.hasTelegramAuth,
reviewDraft: reviewDraft ?? this.reviewDraft, reviewDraft: reviewDraft ?? this.reviewDraft,
); );
} }
@@ -59,11 +67,14 @@ class PlaceController extends AsyncNotifier<PlaceState> {
@override @override
Future<PlaceState> build() async { Future<PlaceState> build() async {
final currentUser = await _api.authenticateTelegram();
final places = await _api.fetchPlaces(); final places = await _api.fetchPlaces();
return PlaceState( return PlaceState(
selectedTrait: PlaceTrait.calm, selectedTrait: PlaceTrait.calm,
places: places, places: places,
selectedPlaceId: places.isEmpty ? null : places.first.id, selectedPlaceId: places.isEmpty ? null : places.first.id,
currentUser: currentUser,
hasTelegramAuth: _api.hasTelegramAuth,
reviewDraft: const VoiceReviewDraft( reviewDraft: const VoiceReviewDraft(
placeName: '', placeName: '',
duration: Duration.zero, duration: Duration.zero,
@@ -112,6 +123,10 @@ class PlaceController extends AsyncNotifier<PlaceState> {
Future<void> publishReview({LatLng? coordinate}) async { Future<void> publishReview({LatLng? coordinate}) async {
final value = state.requireValue; final value = state.requireValue;
if (!value.hasTelegramAuth) {
throw StateError('Открой через Telegram, чтобы оставить голос.');
}
final draft = value.reviewDraft; final draft = value.reviewDraft;
final placeName = draft.placeName.trim().isEmpty final placeName = draft.placeName.trim().isEmpty
? 'Место на карте' ? 'Место на карте'