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: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 ?? {}}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
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: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,
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
? 'Место на карте'
|
? 'Место на карте'
|
||||||
|
|||||||
Reference in New Issue
Block a user