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: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<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 {
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 ?? {}}),
);

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: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 {
calm,
dynamic,

View File

@@ -110,7 +110,10 @@ class _MapContent extends ConsumerWidget {
Navigator.of(context).push(
MaterialPageRoute<void>(
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<AddExperienceFlow> createState() => _AddExperienceFlowState();
@@ -474,12 +482,13 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
),
_ => _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),
),

View File

@@ -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<PlaceRecommendation> places;
final String? selectedPlaceId;
final AppUser? currentUser;
final bool hasTelegramAuth;
final VoiceReviewDraft reviewDraft;
List<PlaceRecommendation> get recommendations {
@@ -43,12 +47,16 @@ class PlaceState {
PlaceTrait? selectedTrait,
List<PlaceRecommendation>? 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<PlaceState> {
@override
Future<PlaceState> 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<PlaceState> {
Future<void> publishReview({LatLng? coordinate}) async {
final value = state.requireValue;
if (!value.hasTelegramAuth) {
throw StateError('Открой через Telegram, чтобы оставить голос.');
}
final draft = value.reviewDraft;
final placeName = draft.placeName.trim().isEmpty
? 'Место на карте'