Authenticate reviews with Telegram init data
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 3m1s
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 3m1s
This commit is contained in:
@@ -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 ?? {}}),
|
||||
);
|
||||
|
||||
|
||||
2
lib/auth/telegram_init_data.dart
Normal file
2
lib/auth/telegram_init_data.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'telegram_init_data_stub.dart'
|
||||
if (dart.library.js_interop) 'telegram_init_data_web.dart';
|
||||
1
lib/auth/telegram_init_data_stub.dart
Normal file
1
lib/auth/telegram_init_data_stub.dart
Normal file
@@ -0,0 +1 @@
|
||||
String telegramInitData() => '';
|
||||
6
lib/auth/telegram_init_data_web.dart
Normal file
6
lib/auth/telegram_init_data_web.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
@JS('window.Telegram.WebApp.initData')
|
||||
external JSString? get _telegramInitData;
|
||||
|
||||
String telegramInitData() => _telegramInitData?.toDart ?? '';
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
? 'Место на карте'
|
||||
|
||||
Reference in New Issue
Block a user