This commit is contained in:
+51
-26
@@ -5,7 +5,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../features/mapflow/application/place_cubit.dart';
|
||||
import '../features/mapflow/data/mapflow_api.dart';
|
||||
import '../features/mapflow/data/auth_repository.dart';
|
||||
import '../features/mapflow/data/mapflow_graphql_client.dart';
|
||||
import '../features/mapflow/data/places_repository.dart';
|
||||
import '../features/mapflow/data/voice_experiences_repository.dart';
|
||||
import '../shared/location/current_location.dart';
|
||||
import 'router/app_router.dart';
|
||||
import 'theme/mapflow_theme.dart';
|
||||
@@ -18,14 +21,28 @@ class MapflowApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MapflowAppState extends State<MapflowApp> {
|
||||
late final MapflowGraphqlClient _graphqlClient;
|
||||
late final AuthRepository _authRepository;
|
||||
late final PlacesRepository _placesRepository;
|
||||
late final VoiceExperiencesRepository _voiceExperiencesRepository;
|
||||
late final PlaceCubit _placeCubit;
|
||||
late final GoRouter _router;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_placeCubit = PlaceCubit(api: MapflowApi(), location: CurrentLocation())
|
||||
..load();
|
||||
_graphqlClient = MapflowGraphqlClient();
|
||||
_authRepository = AuthRepository(client: _graphqlClient);
|
||||
_placesRepository = PlacesRepository(client: _graphqlClient);
|
||||
_voiceExperiencesRepository = VoiceExperiencesRepository(
|
||||
client: _graphqlClient,
|
||||
);
|
||||
_placeCubit = PlaceCubit(
|
||||
authRepository: _authRepository,
|
||||
placesRepository: _placesRepository,
|
||||
voiceExperiencesRepository: _voiceExperiencesRepository,
|
||||
location: CurrentLocation(),
|
||||
)..load();
|
||||
_router = createAppRouter();
|
||||
}
|
||||
|
||||
@@ -33,6 +50,7 @@ class _MapflowAppState extends State<MapflowApp> {
|
||||
void dispose() {
|
||||
_router.dispose();
|
||||
_placeCubit.close();
|
||||
_graphqlClient.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -45,31 +63,38 @@ class _MapflowAppState extends State<MapflowApp> {
|
||||
..[const SingleActivator(LogicalKeyboardKey.tab, shift: true)] =
|
||||
const PreviousFocusIntent();
|
||||
|
||||
return BlocProvider.value(
|
||||
value: _placeCubit,
|
||||
child: MaterialApp.router(
|
||||
title: 'MapFlow',
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: _router,
|
||||
scrollBehavior: const MaterialScrollBehavior().copyWith(
|
||||
scrollbars: true,
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
PointerDeviceKind.stylus,
|
||||
return MultiRepositoryProvider(
|
||||
providers: [
|
||||
RepositoryProvider.value(value: _authRepository),
|
||||
RepositoryProvider.value(value: _placesRepository),
|
||||
RepositoryProvider.value(value: _voiceExperiencesRepository),
|
||||
],
|
||||
child: BlocProvider.value(
|
||||
value: _placeCubit,
|
||||
child: MaterialApp.router(
|
||||
title: 'MapFlow',
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: _router,
|
||||
scrollBehavior: const MaterialScrollBehavior().copyWith(
|
||||
scrollbars: true,
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
PointerDeviceKind.stylus,
|
||||
},
|
||||
),
|
||||
theme: MapflowTheme.light(),
|
||||
builder: (context, child) {
|
||||
return Shortcuts(
|
||||
shortcuts: shortcuts,
|
||||
child: FocusTraversalGroup(
|
||||
policy: ReadingOrderTraversalPolicy(),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
theme: MapflowTheme.light(),
|
||||
builder: (context, child) {
|
||||
return Shortcuts(
|
||||
shortcuts: shortcuts,
|
||||
child: FocusTraversalGroup(
|
||||
policy: ReadingOrderTraversalPolicy(),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../../shared/location/current_location.dart';
|
||||
import '../data/mapflow_api.dart';
|
||||
import '../data/auth_repository.dart';
|
||||
import '../data/places_repository.dart';
|
||||
import '../data/voice_experiences_repository.dart';
|
||||
import '../domain/place_models.dart';
|
||||
|
||||
const _unset = Object();
|
||||
@@ -117,25 +119,33 @@ class PlaceViewState {
|
||||
}
|
||||
|
||||
class PlaceCubit extends Cubit<PlaceViewState> {
|
||||
PlaceCubit({required MapflowApi api, required CurrentLocation location})
|
||||
: _api = api,
|
||||
_location = location,
|
||||
super(const PlaceViewState.loading());
|
||||
PlaceCubit({
|
||||
required AuthRepository authRepository,
|
||||
required PlacesRepository placesRepository,
|
||||
required VoiceExperiencesRepository voiceExperiencesRepository,
|
||||
required CurrentLocation location,
|
||||
}) : _authRepository = authRepository,
|
||||
_placesRepository = placesRepository,
|
||||
_voiceExperiencesRepository = voiceExperiencesRepository,
|
||||
_location = location,
|
||||
super(const PlaceViewState.loading());
|
||||
|
||||
final MapflowApi _api;
|
||||
final AuthRepository _authRepository;
|
||||
final PlacesRepository _placesRepository;
|
||||
final VoiceExperiencesRepository _voiceExperiencesRepository;
|
||||
final CurrentLocation _location;
|
||||
|
||||
Future<void> load() async {
|
||||
emit(const PlaceViewState.loading());
|
||||
|
||||
if (!_api.hasTelegramAuth) {
|
||||
if (!_authRepository.hasTelegramAuth) {
|
||||
emit(PlaceViewState.ready(_emptyState(hasTelegramAuth: false)));
|
||||
return;
|
||||
}
|
||||
|
||||
final currentUser = await _api.authenticateTelegram();
|
||||
final currentUser = await _authRepository.authenticateTelegram();
|
||||
final userCoordinate = await _location.resolve();
|
||||
final places = await _api.fetchPlaces();
|
||||
final places = await _placesRepository.fetchPlaces();
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
PlaceState(
|
||||
@@ -143,7 +153,7 @@ class PlaceCubit extends Cubit<PlaceViewState> {
|
||||
places: places,
|
||||
selectedPlaceId: places.isEmpty ? null : places.first.id,
|
||||
currentUser: currentUser,
|
||||
hasTelegramAuth: _api.hasTelegramAuth,
|
||||
hasTelegramAuth: _authRepository.hasTelegramAuth,
|
||||
userCoordinate: userCoordinate,
|
||||
reviewDraft: _emptyDraft,
|
||||
),
|
||||
@@ -216,7 +226,7 @@ class PlaceCubit extends Cubit<PlaceViewState> {
|
||||
}
|
||||
|
||||
final draft = value.reviewDraft;
|
||||
await _api.createVoiceExperience(
|
||||
await _voiceExperiencesRepository.createVoiceExperience(
|
||||
googlePlaceId: place.googlePlaceId,
|
||||
googleName: place.name,
|
||||
coordinate: place.coordinate,
|
||||
@@ -226,7 +236,7 @@ class PlaceCubit extends Cubit<PlaceViewState> {
|
||||
audioMimeType: audioMimeType,
|
||||
);
|
||||
|
||||
final places = await _api.fetchPlaces();
|
||||
final places = await _placesRepository.fetchPlaces();
|
||||
final selectedPlace = places.isEmpty ? null : places.first.id;
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../domain/place_models.dart';
|
||||
import 'graphql/__generated__/authenticate_telegram.req.gql.dart';
|
||||
import 'graphql/__generated__/authenticate_telegram_login.req.gql.dart';
|
||||
import 'graphql/__generated__/complete_telegram_bot_login.req.gql.dart';
|
||||
import 'graphql/__generated__/me.req.gql.dart';
|
||||
import 'graphql/__generated__/start_telegram_bot_login.req.gql.dart';
|
||||
import 'mapflow_data_mappers.dart';
|
||||
import 'mapflow_graphql_client.dart';
|
||||
|
||||
class AuthRepository {
|
||||
const AuthRepository({required MapflowGraphqlClient client})
|
||||
: _client = client;
|
||||
|
||||
final MapflowGraphqlClient _client;
|
||||
|
||||
bool get hasTelegramAuth => _client.hasTelegramAuth;
|
||||
|
||||
Future<AppUser?> authenticateTelegram() async {
|
||||
if (!hasTelegramAuth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_client.mapflowSessionToken.isNotEmpty) {
|
||||
final data = await _client.request(GMeReq());
|
||||
return appUserFromMe(data.me);
|
||||
}
|
||||
|
||||
if (_client.telegramLoginData.isNotEmpty) {
|
||||
final loginData =
|
||||
jsonDecode(_client.telegramLoginData) as Map<String, dynamic>;
|
||||
final data = await _client.request(
|
||||
GAuthenticateTelegramLoginReq((b) {
|
||||
b.vars.input
|
||||
..id = _requiredDouble(loginData, 'id')
|
||||
..first_name = _optionalString(loginData, 'first_name')
|
||||
..last_name = _optionalString(loginData, 'last_name')
|
||||
..username = _optionalString(loginData, 'username')
|
||||
..photo_url = _optionalString(loginData, 'photo_url')
|
||||
..auth_date = _requiredDouble(loginData, 'auth_date')
|
||||
..hash = _requiredString(loginData, 'hash');
|
||||
}),
|
||||
);
|
||||
|
||||
return appUserFromAuthenticateTelegramLogin(
|
||||
data.authenticateTelegramLogin.user,
|
||||
);
|
||||
}
|
||||
|
||||
final data = await _client.request(
|
||||
GAuthenticateTelegramReq((b) {
|
||||
b.vars.input.initData = _client.telegramInitData;
|
||||
}),
|
||||
);
|
||||
return appUserFromAuthenticateTelegram(data.authenticateTelegram.user);
|
||||
}
|
||||
|
||||
Future<TelegramBotLogin> startTelegramBotLogin() async {
|
||||
final data = await _client.request(GStartTelegramBotLoginReq());
|
||||
final payload = data.startTelegramBotLogin;
|
||||
return TelegramBotLogin(
|
||||
token: payload.token,
|
||||
botUrl: payload.botUrl,
|
||||
expiresAt: DateTime.parse(payload.expiresAt),
|
||||
);
|
||||
}
|
||||
|
||||
Future<TelegramBotLoginSession> completeTelegramBotLogin(String token) async {
|
||||
final data = await _client.request(
|
||||
GCompleteTelegramBotLoginReq((b) {
|
||||
b.vars.token = token;
|
||||
}),
|
||||
);
|
||||
final payload = data.completeTelegramBotLogin;
|
||||
return TelegramBotLoginSession(
|
||||
sessionToken: payload.sessionToken,
|
||||
user: appUserFromCompleteTelegramBotLogin(payload.user),
|
||||
);
|
||||
}
|
||||
|
||||
double _requiredDouble(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is num) {
|
||||
return value.toDouble();
|
||||
}
|
||||
|
||||
throw StateError('Telegram login data "$key" must be a number.');
|
||||
}
|
||||
|
||||
String _requiredString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw StateError('Telegram login data "$key" must be a non-empty string.');
|
||||
}
|
||||
|
||||
String? _optionalString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value == null || value is String) {
|
||||
return value as String?;
|
||||
}
|
||||
|
||||
throw StateError('Telegram login data "$key" must be a string.');
|
||||
}
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:built_value/json_object.dart';
|
||||
import 'package:ferry/ferry.dart';
|
||||
import 'package:gql_http_link/gql_http_link.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../../shared/auth/telegram_session.dart' as telegram_auth;
|
||||
import '../domain/place_models.dart';
|
||||
import 'graphql/__generated__/authenticate_telegram.req.gql.dart';
|
||||
import 'graphql/__generated__/authenticate_telegram_login.req.gql.dart';
|
||||
import 'graphql/__generated__/complete_telegram_bot_login.req.gql.dart';
|
||||
import 'graphql/__generated__/create_voice_experience.req.gql.dart';
|
||||
import 'graphql/__generated__/me.req.gql.dart';
|
||||
import 'graphql/__generated__/nearby_places.req.gql.dart';
|
||||
import 'graphql/__generated__/places.req.gql.dart';
|
||||
import 'graphql/__generated__/schema.schema.gql.dart';
|
||||
import 'graphql/__generated__/start_telegram_bot_login.req.gql.dart';
|
||||
import 'graphql/__generated__/voice_experiences.req.gql.dart';
|
||||
|
||||
class MapflowApi {
|
||||
MapflowApi({
|
||||
Client? client,
|
||||
String? telegramInitData,
|
||||
String? telegramLoginData,
|
||||
String? mapflowSessionToken,
|
||||
String endpoint = const String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: '/graphql',
|
||||
),
|
||||
}) : _telegramInitData = telegramInitData ?? telegram_auth.telegramInitData(),
|
||||
_telegramLoginData =
|
||||
telegramLoginData ?? telegram_auth.telegramLoginData(),
|
||||
_mapflowSessionToken =
|
||||
mapflowSessionToken ?? telegram_auth.mapflowSessionToken(),
|
||||
_client =
|
||||
client ??
|
||||
Client(
|
||||
link: HttpLink(
|
||||
Uri.base.resolve(endpoint).toString(),
|
||||
defaultHeaders: _headers(
|
||||
telegramInitData ?? telegram_auth.telegramInitData(),
|
||||
telegramLoginData ?? telegram_auth.telegramLoginData(),
|
||||
mapflowSessionToken ?? telegram_auth.mapflowSessionToken(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final Client _client;
|
||||
final String _telegramInitData;
|
||||
final String _telegramLoginData;
|
||||
final String _mapflowSessionToken;
|
||||
|
||||
bool get hasTelegramAuth =>
|
||||
_telegramInitData.isNotEmpty ||
|
||||
_telegramLoginData.isNotEmpty ||
|
||||
_mapflowSessionToken.isNotEmpty;
|
||||
|
||||
Future<AppUser?> authenticateTelegram() async {
|
||||
if (!hasTelegramAuth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_mapflowSessionToken.isNotEmpty) {
|
||||
final data = await _request(GMeReq());
|
||||
return _appUserFromTyped(data.me);
|
||||
}
|
||||
|
||||
if (_telegramLoginData.isNotEmpty) {
|
||||
final loginData = jsonDecode(_telegramLoginData) as Map<String, dynamic>;
|
||||
final data = await _request(
|
||||
GAuthenticateTelegramLoginReq((b) {
|
||||
b.vars.input
|
||||
..id = _requiredDouble(loginData, 'id')
|
||||
..first_name = _optionalString(loginData, 'first_name')
|
||||
..last_name = _optionalString(loginData, 'last_name')
|
||||
..username = _optionalString(loginData, 'username')
|
||||
..photo_url = _optionalString(loginData, 'photo_url')
|
||||
..auth_date = _requiredDouble(loginData, 'auth_date')
|
||||
..hash = _requiredString(loginData, 'hash');
|
||||
}),
|
||||
);
|
||||
|
||||
return _appUserFromTyped(data.authenticateTelegramLogin.user);
|
||||
}
|
||||
|
||||
final data = await _request(
|
||||
GAuthenticateTelegramReq((b) {
|
||||
b.vars.input.initData = _telegramInitData;
|
||||
}),
|
||||
);
|
||||
return _appUserFromTyped(data.authenticateTelegram.user);
|
||||
}
|
||||
|
||||
Future<TelegramBotLogin> startTelegramBotLogin() async {
|
||||
final data = await _request(GStartTelegramBotLoginReq());
|
||||
final payload = data.startTelegramBotLogin;
|
||||
return TelegramBotLogin(
|
||||
token: payload.token,
|
||||
botUrl: payload.botUrl,
|
||||
expiresAt: DateTime.parse(payload.expiresAt),
|
||||
);
|
||||
}
|
||||
|
||||
Future<TelegramBotLoginSession> completeTelegramBotLogin(String token) async {
|
||||
final data = await _request(
|
||||
GCompleteTelegramBotLoginReq((b) {
|
||||
b.vars.token = token;
|
||||
}),
|
||||
);
|
||||
final payload = data.completeTelegramBotLogin;
|
||||
return TelegramBotLoginSession(
|
||||
sessionToken: payload.sessionToken,
|
||||
user: _appUserFromTyped(payload.user),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<PlaceRecommendation>> fetchPlaces() async {
|
||||
final data = await _request(GPlacesReq());
|
||||
return data.places.map(_placeRecommendationFromTyped).toList();
|
||||
}
|
||||
|
||||
Future<List<PlaceRecommendation>> fetchNearbyPlaces({
|
||||
required LatLng coordinate,
|
||||
required int radiusMeters,
|
||||
}) async {
|
||||
final data = await _request(
|
||||
GNearbyPlacesReq((b) {
|
||||
b.vars.input
|
||||
..latitude = coordinate.latitude
|
||||
..longitude = coordinate.longitude
|
||||
..radiusMeters = radiusMeters;
|
||||
}),
|
||||
);
|
||||
return data.nearbyPlaces.map(_placeRecommendationFromTyped).toList();
|
||||
}
|
||||
|
||||
Future<void> createVoiceExperience({
|
||||
required String googlePlaceId,
|
||||
required String googleName,
|
||||
required LatLng coordinate,
|
||||
required int durationSeconds,
|
||||
required String audioObjectKey,
|
||||
required String audioContentBase64,
|
||||
required String audioMimeType,
|
||||
}) async {
|
||||
if (!hasTelegramAuth) {
|
||||
throw StateError('Telegram authorization is required.');
|
||||
}
|
||||
|
||||
await _request(
|
||||
GCreateVoiceExperienceReq((b) {
|
||||
b.vars.input
|
||||
..googlePlaceId = googlePlaceId
|
||||
..googleName = googleName
|
||||
..latitude = coordinate.latitude
|
||||
..longitude = coordinate.longitude
|
||||
..durationSeconds = durationSeconds
|
||||
..audioObjectKey = audioObjectKey
|
||||
..audioContentBase64 = audioContentBase64
|
||||
..audioMimeType = audioMimeType;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<VoiceExperienceDebug>> fetchVoiceExperiences() async {
|
||||
final data = await _request(GVoiceExperiencesReq());
|
||||
return data.voiceExperiences.map(_voiceExperienceDebugFromTyped).toList();
|
||||
}
|
||||
|
||||
Future<TData> _request<TData, TVars>(
|
||||
OperationRequest<TData, TVars> request,
|
||||
) async {
|
||||
final response = await _client
|
||||
.request(request)
|
||||
.firstWhere((item) => !item.loading);
|
||||
|
||||
if (response.hasErrors) {
|
||||
throw StateError(_errorMessage(response));
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('GraphQL response data is empty.');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static Map<String, String> _headers(
|
||||
String telegramInitData,
|
||||
String telegramLoginData,
|
||||
String mapflowSessionToken,
|
||||
) {
|
||||
return {
|
||||
if (telegramInitData.isNotEmpty) 'x-telegram-init-data': telegramInitData,
|
||||
if (telegramLoginData.isNotEmpty)
|
||||
'x-telegram-login-data': telegramLoginData,
|
||||
if (mapflowSessionToken.isNotEmpty)
|
||||
'x-mapflow-session-token': mapflowSessionToken,
|
||||
};
|
||||
}
|
||||
|
||||
String _errorMessage(OperationResponse<dynamic, dynamic> response) {
|
||||
final graphQLErrors = response.graphqlErrors;
|
||||
if (graphQLErrors != null && graphQLErrors.isNotEmpty) {
|
||||
return graphQLErrors.map((error) => error.message).join('\n');
|
||||
}
|
||||
|
||||
final linkException = response.linkException;
|
||||
if (linkException != null) {
|
||||
return linkException.toString();
|
||||
}
|
||||
|
||||
return 'GraphQL request failed.';
|
||||
}
|
||||
|
||||
AppUser _appUserFromTyped(dynamic user) {
|
||||
return AppUser(
|
||||
id: user.id as String,
|
||||
telegramId: user.telegramId as String,
|
||||
username: user.username as String?,
|
||||
firstName: user.firstName as String?,
|
||||
lastName: user.lastName as String?,
|
||||
photoUrl: user.photoUrl as String?,
|
||||
languageCode: user.languageCode as String?,
|
||||
isAdmin: user.isAdmin as bool,
|
||||
);
|
||||
}
|
||||
|
||||
PlaceRecommendation _placeRecommendationFromTyped(dynamic place) {
|
||||
return PlaceRecommendation(
|
||||
id: place.id as String,
|
||||
googlePlaceId: place.googlePlaceId as String,
|
||||
name: place.name as String,
|
||||
area: '',
|
||||
photoUrls: const [],
|
||||
coordinate: LatLng(place.latitude as double, place.longitude as double),
|
||||
traits: _traitsFromAnalyses(
|
||||
place.experiences.map((dynamic experience) {
|
||||
return experience.analysis as JsonObject?;
|
||||
}),
|
||||
),
|
||||
googlePrimaryType: place.googlePrimaryType as String?,
|
||||
googleTypes: place.googleTypes.toList().cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
VoiceExperienceDebug _voiceExperienceDebugFromTyped(dynamic experience) {
|
||||
return VoiceExperienceDebug(
|
||||
id: experience.id as String,
|
||||
placeName: experience.place.name as String,
|
||||
userName: _userDisplayName(experience.user),
|
||||
status: (experience.status as GVoiceExperienceStatus).name,
|
||||
durationSeconds: experience.durationSeconds as int,
|
||||
transcript: experience.transcript as String?,
|
||||
analysis: _jsonMap(experience.analysis as JsonObject?),
|
||||
createdAt: DateTime.parse(experience.createdAt as String),
|
||||
);
|
||||
}
|
||||
|
||||
String _userDisplayName(dynamic user) {
|
||||
if (user == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final firstName = user.firstName as String?;
|
||||
final username = user.username as String?;
|
||||
final telegramId = user.telegramId as String;
|
||||
if (firstName != null && firstName.trim().isNotEmpty) {
|
||||
return firstName;
|
||||
}
|
||||
if (username != null && username.trim().isNotEmpty) {
|
||||
return '@$username';
|
||||
}
|
||||
return telegramId;
|
||||
}
|
||||
|
||||
Set<PlaceTrait> _traitsFromAnalyses(Iterable<JsonObject?> analyses) {
|
||||
final traits = <PlaceTrait>{};
|
||||
for (final analysisObject in analyses) {
|
||||
final analysis = _jsonMap(analysisObject);
|
||||
if (analysis == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final tags = analysis['tags'];
|
||||
if (tags is! List) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final tag in tags) {
|
||||
final trait = _traitByTag(tag.toString());
|
||||
if (trait != null) {
|
||||
traits.add(trait);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return traits;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _jsonMap(JsonObject? object) {
|
||||
if (object == null || !object.isMap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return object.asMap.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
|
||||
double _requiredDouble(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is num) {
|
||||
return value.toDouble();
|
||||
}
|
||||
|
||||
throw StateError('Telegram login data "$key" must be a number.');
|
||||
}
|
||||
|
||||
String _requiredString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw StateError('Telegram login data "$key" must be a non-empty string.');
|
||||
}
|
||||
|
||||
String? _optionalString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value == null || value is String) {
|
||||
return value as String?;
|
||||
}
|
||||
|
||||
throw StateError('Telegram login data "$key" must be a string.');
|
||||
}
|
||||
|
||||
PlaceTrait? _traitByTag(String tag) {
|
||||
final name = tag.contains(':') ? tag.split(':').last : tag;
|
||||
for (final trait in PlaceTrait.values) {
|
||||
if (trait.name == name) {
|
||||
return trait;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'package:built_value/json_object.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../domain/place_models.dart';
|
||||
import 'graphql/__generated__/authenticate_telegram.data.gql.dart';
|
||||
import 'graphql/__generated__/authenticate_telegram_login.data.gql.dart';
|
||||
import 'graphql/__generated__/complete_telegram_bot_login.data.gql.dart';
|
||||
import 'graphql/__generated__/me.data.gql.dart';
|
||||
import 'graphql/__generated__/nearby_places.data.gql.dart';
|
||||
import 'graphql/__generated__/places.data.gql.dart';
|
||||
import 'graphql/__generated__/voice_experiences.data.gql.dart';
|
||||
|
||||
AppUser appUserFromMe(GMeData_me user) {
|
||||
return _appUser(
|
||||
id: user.id,
|
||||
telegramId: user.telegramId,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
photoUrl: user.photoUrl,
|
||||
languageCode: user.languageCode,
|
||||
isAdmin: user.isAdmin,
|
||||
);
|
||||
}
|
||||
|
||||
AppUser appUserFromAuthenticateTelegram(
|
||||
GAuthenticateTelegramData_authenticateTelegram_user user,
|
||||
) {
|
||||
return _appUser(
|
||||
id: user.id,
|
||||
telegramId: user.telegramId,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
photoUrl: user.photoUrl,
|
||||
languageCode: user.languageCode,
|
||||
isAdmin: user.isAdmin,
|
||||
);
|
||||
}
|
||||
|
||||
AppUser appUserFromAuthenticateTelegramLogin(
|
||||
GAuthenticateTelegramLoginData_authenticateTelegramLogin_user user,
|
||||
) {
|
||||
return _appUser(
|
||||
id: user.id,
|
||||
telegramId: user.telegramId,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
photoUrl: user.photoUrl,
|
||||
languageCode: user.languageCode,
|
||||
isAdmin: user.isAdmin,
|
||||
);
|
||||
}
|
||||
|
||||
AppUser appUserFromCompleteTelegramBotLogin(
|
||||
GCompleteTelegramBotLoginData_completeTelegramBotLogin_user user,
|
||||
) {
|
||||
return _appUser(
|
||||
id: user.id,
|
||||
telegramId: user.telegramId,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
photoUrl: user.photoUrl,
|
||||
languageCode: user.languageCode,
|
||||
isAdmin: user.isAdmin,
|
||||
);
|
||||
}
|
||||
|
||||
AppUser _appUser({
|
||||
required String id,
|
||||
required String telegramId,
|
||||
required String? username,
|
||||
required String? firstName,
|
||||
required String? lastName,
|
||||
required String? photoUrl,
|
||||
required String? languageCode,
|
||||
required bool isAdmin,
|
||||
}) {
|
||||
return AppUser(
|
||||
id: id,
|
||||
telegramId: telegramId,
|
||||
username: username,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
photoUrl: photoUrl,
|
||||
languageCode: languageCode,
|
||||
isAdmin: isAdmin,
|
||||
);
|
||||
}
|
||||
|
||||
PlaceRecommendation placeRecommendationFromPlace(GPlacesData_places place) {
|
||||
return PlaceRecommendation(
|
||||
id: place.id,
|
||||
googlePlaceId: place.googlePlaceId,
|
||||
name: place.name,
|
||||
area: '',
|
||||
photoUrls: const [],
|
||||
coordinate: LatLng(place.latitude, place.longitude),
|
||||
traits: _traitsFromAnalyses(place.experiences.map((item) => item.analysis)),
|
||||
googlePrimaryType: place.googlePrimaryType,
|
||||
googleTypes: place.googleTypes.toList().cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
PlaceRecommendation placeRecommendationFromNearbyPlace(
|
||||
GNearbyPlacesData_nearbyPlaces place,
|
||||
) {
|
||||
return PlaceRecommendation(
|
||||
id: place.id,
|
||||
googlePlaceId: place.googlePlaceId,
|
||||
name: place.name,
|
||||
area: '',
|
||||
photoUrls: const [],
|
||||
coordinate: LatLng(place.latitude, place.longitude),
|
||||
traits: _traitsFromAnalyses(place.experiences.map((item) => item.analysis)),
|
||||
googlePrimaryType: place.googlePrimaryType,
|
||||
googleTypes: place.googleTypes.toList().cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
VoiceExperienceDebug voiceExperienceDebugFromVoiceExperience(
|
||||
GVoiceExperiencesData_voiceExperiences experience,
|
||||
) {
|
||||
return VoiceExperienceDebug(
|
||||
id: experience.id,
|
||||
placeName: experience.place.name,
|
||||
userName: _userDisplayName(experience.user),
|
||||
status: experience.status.name,
|
||||
durationSeconds: experience.durationSeconds,
|
||||
transcript: experience.transcript,
|
||||
analysis: _jsonMap(experience.analysis),
|
||||
createdAt: DateTime.parse(experience.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
String _userDisplayName(GVoiceExperiencesData_voiceExperiences_user? user) {
|
||||
if (user == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final firstName = user.firstName;
|
||||
final username = user.username;
|
||||
final telegramId = user.telegramId;
|
||||
if (firstName != null && firstName.trim().isNotEmpty) {
|
||||
return firstName;
|
||||
}
|
||||
if (username != null && username.trim().isNotEmpty) {
|
||||
return '@$username';
|
||||
}
|
||||
return telegramId;
|
||||
}
|
||||
|
||||
Set<PlaceTrait> _traitsFromAnalyses(Iterable<JsonObject?> analyses) {
|
||||
final traits = <PlaceTrait>{};
|
||||
for (final analysisObject in analyses) {
|
||||
final analysis = _jsonMap(analysisObject);
|
||||
if (analysis == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final tags = analysis['tags'];
|
||||
if (tags is! List) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final tag in tags) {
|
||||
final trait = _traitByTag(tag.toString());
|
||||
if (trait != null) {
|
||||
traits.add(trait);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return traits;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _jsonMap(JsonObject? object) {
|
||||
if (object == null || !object.isMap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return object.asMap.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
|
||||
PlaceTrait? _traitByTag(String tag) {
|
||||
final name = tag.contains(':') ? tag.split(':').last : tag;
|
||||
for (final trait in PlaceTrait.values) {
|
||||
if (trait.name == name) {
|
||||
return trait;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:ferry/ferry.dart';
|
||||
import 'package:gql_http_link/gql_http_link.dart';
|
||||
|
||||
import '../../../shared/auth/telegram_session.dart' as telegram_auth;
|
||||
|
||||
class MapflowGraphqlClient {
|
||||
factory MapflowGraphqlClient({
|
||||
Client? client,
|
||||
String? telegramInitData,
|
||||
String? telegramLoginData,
|
||||
String? mapflowSessionToken,
|
||||
String endpoint = const String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: '/graphql',
|
||||
),
|
||||
}) {
|
||||
final resolvedTelegramInitData =
|
||||
telegramInitData ?? telegram_auth.telegramInitData();
|
||||
final resolvedTelegramLoginData =
|
||||
telegramLoginData ?? telegram_auth.telegramLoginData();
|
||||
final resolvedMapflowSessionToken =
|
||||
mapflowSessionToken ?? telegram_auth.mapflowSessionToken();
|
||||
|
||||
return MapflowGraphqlClient._(
|
||||
client:
|
||||
client ??
|
||||
Client(
|
||||
link: HttpLink(
|
||||
Uri.base.resolve(endpoint).toString(),
|
||||
defaultHeaders: _headers(
|
||||
resolvedTelegramInitData,
|
||||
resolvedTelegramLoginData,
|
||||
resolvedMapflowSessionToken,
|
||||
),
|
||||
),
|
||||
),
|
||||
telegramInitData: resolvedTelegramInitData,
|
||||
telegramLoginData: resolvedTelegramLoginData,
|
||||
mapflowSessionToken: resolvedMapflowSessionToken,
|
||||
);
|
||||
}
|
||||
|
||||
MapflowGraphqlClient._({
|
||||
required Client client,
|
||||
required this.telegramInitData,
|
||||
required this.telegramLoginData,
|
||||
required this.mapflowSessionToken,
|
||||
}) : _client = client;
|
||||
|
||||
final Client _client;
|
||||
final String telegramInitData;
|
||||
final String telegramLoginData;
|
||||
final String mapflowSessionToken;
|
||||
|
||||
bool get hasTelegramAuth =>
|
||||
telegramInitData.isNotEmpty ||
|
||||
telegramLoginData.isNotEmpty ||
|
||||
mapflowSessionToken.isNotEmpty;
|
||||
|
||||
Future<TData> request<TData, TVars>(
|
||||
OperationRequest<TData, TVars> request,
|
||||
) async {
|
||||
final response = await _client
|
||||
.request(request)
|
||||
.firstWhere((item) => !item.loading);
|
||||
|
||||
if (response.hasErrors) {
|
||||
throw StateError(_errorMessage(response));
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('GraphQL response data is empty.');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
unawaited(_client.dispose());
|
||||
}
|
||||
|
||||
static Map<String, String> _headers(
|
||||
String telegramInitData,
|
||||
String telegramLoginData,
|
||||
String mapflowSessionToken,
|
||||
) {
|
||||
return {
|
||||
if (telegramInitData.isNotEmpty) 'x-telegram-init-data': telegramInitData,
|
||||
if (telegramLoginData.isNotEmpty)
|
||||
'x-telegram-login-data': telegramLoginData,
|
||||
if (mapflowSessionToken.isNotEmpty)
|
||||
'x-mapflow-session-token': mapflowSessionToken,
|
||||
};
|
||||
}
|
||||
|
||||
String _errorMessage(OperationResponse<dynamic, dynamic> response) {
|
||||
final graphQLErrors = response.graphqlErrors;
|
||||
if (graphQLErrors != null && graphQLErrors.isNotEmpty) {
|
||||
return graphQLErrors.map((error) => error.message).join('\n');
|
||||
}
|
||||
|
||||
final linkException = response.linkException;
|
||||
if (linkException != null) {
|
||||
return linkException.toString();
|
||||
}
|
||||
|
||||
return 'GraphQL request failed.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../domain/place_models.dart';
|
||||
import 'graphql/__generated__/nearby_places.req.gql.dart';
|
||||
import 'graphql/__generated__/places.req.gql.dart';
|
||||
import 'mapflow_data_mappers.dart';
|
||||
import 'mapflow_graphql_client.dart';
|
||||
|
||||
class PlacesRepository {
|
||||
const PlacesRepository({required MapflowGraphqlClient client})
|
||||
: _client = client;
|
||||
|
||||
final MapflowGraphqlClient _client;
|
||||
|
||||
Future<List<PlaceRecommendation>> fetchPlaces() async {
|
||||
final data = await _client.request(GPlacesReq());
|
||||
return data.places.map(placeRecommendationFromPlace).toList();
|
||||
}
|
||||
|
||||
Future<List<PlaceRecommendation>> fetchNearbyPlaces({
|
||||
required LatLng coordinate,
|
||||
required int radiusMeters,
|
||||
}) async {
|
||||
final data = await _client.request(
|
||||
GNearbyPlacesReq((b) {
|
||||
b.vars.input
|
||||
..latitude = coordinate.latitude
|
||||
..longitude = coordinate.longitude
|
||||
..radiusMeters = radiusMeters;
|
||||
}),
|
||||
);
|
||||
return data.nearbyPlaces.map(placeRecommendationFromNearbyPlace).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../domain/place_models.dart';
|
||||
import 'graphql/__generated__/create_voice_experience.req.gql.dart';
|
||||
import 'graphql/__generated__/voice_experiences.req.gql.dart';
|
||||
import 'mapflow_data_mappers.dart';
|
||||
import 'mapflow_graphql_client.dart';
|
||||
|
||||
class VoiceExperiencesRepository {
|
||||
const VoiceExperiencesRepository({required MapflowGraphqlClient client})
|
||||
: _client = client;
|
||||
|
||||
final MapflowGraphqlClient _client;
|
||||
|
||||
Future<void> createVoiceExperience({
|
||||
required String googlePlaceId,
|
||||
required String googleName,
|
||||
required LatLng coordinate,
|
||||
required int durationSeconds,
|
||||
required String audioObjectKey,
|
||||
required String audioContentBase64,
|
||||
required String audioMimeType,
|
||||
}) async {
|
||||
if (!_client.hasTelegramAuth) {
|
||||
throw StateError('Telegram authorization is required.');
|
||||
}
|
||||
|
||||
await _client.request(
|
||||
GCreateVoiceExperienceReq((b) {
|
||||
b.vars.input
|
||||
..googlePlaceId = googlePlaceId
|
||||
..googleName = googleName
|
||||
..latitude = coordinate.latitude
|
||||
..longitude = coordinate.longitude
|
||||
..durationSeconds = durationSeconds
|
||||
..audioObjectKey = audioObjectKey
|
||||
..audioContentBase64 = audioContentBase64
|
||||
..audioMimeType = audioMimeType;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<VoiceExperienceDebug>> fetchVoiceExperiences() async {
|
||||
final data = await _client.request(GVoiceExperiencesReq());
|
||||
return data.voiceExperiences
|
||||
.map(voiceExperienceDebugFromVoiceExperience)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../app/theme/mapflow_theme.dart';
|
||||
import '../data/mapflow_api.dart';
|
||||
import '../data/voice_experiences_repository.dart';
|
||||
import '../domain/place_models.dart';
|
||||
|
||||
class AdminVoiceExperiencesScreen extends StatefulWidget {
|
||||
@@ -16,7 +17,8 @@ class AdminVoiceExperiencesScreen extends StatefulWidget {
|
||||
|
||||
class _AdminVoiceExperiencesScreenState
|
||||
extends State<AdminVoiceExperiencesScreen> {
|
||||
late final Future<List<VoiceExperienceDebug>> _future = MapflowApi()
|
||||
late final Future<List<VoiceExperienceDebug>> _future = context
|
||||
.read<VoiceExperiencesRepository>()
|
||||
.fetchVoiceExperiences();
|
||||
|
||||
@override
|
||||
|
||||
@@ -16,7 +16,8 @@ import '../../../app/theme/mapflow_theme.dart';
|
||||
import '../../../shared/auth/telegram_login_button.dart';
|
||||
import '../../../shared/auth/telegram_session.dart' as telegram_session;
|
||||
import '../application/place_cubit.dart';
|
||||
import '../data/mapflow_api.dart';
|
||||
import '../data/auth_repository.dart';
|
||||
import '../data/places_repository.dart';
|
||||
import '../domain/place_models.dart';
|
||||
import 'widgets/place_photo_card.dart';
|
||||
|
||||
@@ -376,13 +377,14 @@ class _TelegramLoginScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TelegramLoginScreenState extends State<_TelegramLoginScreen> {
|
||||
final _api = MapflowApi();
|
||||
late final AuthRepository _authRepository;
|
||||
var _loading = false;
|
||||
var _message = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_authRepository = context.read<AuthRepository>();
|
||||
final urlToken = telegram_session.telegramLoginTokenFromUrl();
|
||||
if (urlToken.isNotEmpty) {
|
||||
_completeLogin(urlToken);
|
||||
@@ -394,7 +396,7 @@ class _TelegramLoginScreenState extends State<_TelegramLoginScreen> {
|
||||
_loading = true;
|
||||
_message = '';
|
||||
});
|
||||
final login = await _api.startTelegramBotLogin();
|
||||
final login = await _authRepository.startTelegramBotLogin();
|
||||
telegram_session.openExternalUrl(login.botUrl);
|
||||
setState(() {
|
||||
_loading = false;
|
||||
@@ -407,7 +409,7 @@ class _TelegramLoginScreenState extends State<_TelegramLoginScreen> {
|
||||
_loading = true;
|
||||
_message = '';
|
||||
});
|
||||
final session = await _api.completeTelegramBotLogin(token);
|
||||
final session = await _authRepository.completeTelegramBotLogin(token);
|
||||
telegram_session.saveMapflowSessionToken(session.sessionToken);
|
||||
widget.onAuthenticated();
|
||||
telegram_session.reloadApp();
|
||||
@@ -667,7 +669,6 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
|
||||
static const _minimumInformationUnits = 16.0;
|
||||
static const _nearbyPlaceRadiusMeters = 50;
|
||||
|
||||
final _api = MapflowApi();
|
||||
final _waveController = WaveformRecorderController(
|
||||
interval: const Duration(milliseconds: 45),
|
||||
config: const RecordConfig(
|
||||
@@ -718,7 +719,7 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
|
||||
return const [];
|
||||
}
|
||||
|
||||
return _api.fetchNearbyPlaces(
|
||||
return context.read<PlacesRepository>().fetchNearbyPlaces(
|
||||
coordinate: coordinate,
|
||||
radiusMeters: _nearbyPlaceRadiusMeters,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user