diff --git a/lib/app/app.dart b/lib/app/app.dart index fc50f8f..6998761 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -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 { + 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 { void dispose() { _router.dispose(); _placeCubit.close(); + _graphqlClient.dispose(); super.dispose(); } @@ -45,31 +63,38 @@ class _MapflowAppState extends State { ..[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(), - ), - ); - }, ), ); } diff --git a/lib/features/mapflow/application/place_cubit.dart b/lib/features/mapflow/application/place_cubit.dart index feb72a6..2c51c98 100644 --- a/lib/features/mapflow/application/place_cubit.dart +++ b/lib/features/mapflow/application/place_cubit.dart @@ -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 { - 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 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 { 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 { } 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 { audioMimeType: audioMimeType, ); - final places = await _api.fetchPlaces(); + final places = await _placesRepository.fetchPlaces(); final selectedPlace = places.isEmpty ? null : places.first.id; emit( PlaceViewState.ready( diff --git a/lib/features/mapflow/data/auth_repository.dart b/lib/features/mapflow/data/auth_repository.dart new file mode 100644 index 0000000..2dad7eb --- /dev/null +++ b/lib/features/mapflow/data/auth_repository.dart @@ -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 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; + 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 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 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 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 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 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.'); + } +} diff --git a/lib/features/mapflow/data/mapflow_api.dart b/lib/features/mapflow/data/mapflow_api.dart deleted file mode 100644 index 9c512e7..0000000 --- a/lib/features/mapflow/data/mapflow_api.dart +++ /dev/null @@ -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 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; - 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 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 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> fetchPlaces() async { - final data = await _request(GPlacesReq()); - return data.places.map(_placeRecommendationFromTyped).toList(); - } - - Future> 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 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> fetchVoiceExperiences() async { - final data = await _request(GVoiceExperiencesReq()); - return data.voiceExperiences.map(_voiceExperienceDebugFromTyped).toList(); - } - - Future _request( - OperationRequest 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 _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 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(), - ); - } - - 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 _traitsFromAnalyses(Iterable analyses) { - final traits = {}; - 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? _jsonMap(JsonObject? object) { - if (object == null || !object.isMap) { - return null; - } - - return object.asMap.map((key, value) => MapEntry(key.toString(), value)); - } - - double _requiredDouble(Map 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 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 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; - } -} diff --git a/lib/features/mapflow/data/mapflow_data_mappers.dart b/lib/features/mapflow/data/mapflow_data_mappers.dart new file mode 100644 index 0000000..c2aeb93 --- /dev/null +++ b/lib/features/mapflow/data/mapflow_data_mappers.dart @@ -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(), + ); +} + +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(), + ); +} + +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 _traitsFromAnalyses(Iterable analyses) { + final traits = {}; + 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? _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; +} diff --git a/lib/features/mapflow/data/mapflow_graphql_client.dart b/lib/features/mapflow/data/mapflow_graphql_client.dart new file mode 100644 index 0000000..782f7d0 --- /dev/null +++ b/lib/features/mapflow/data/mapflow_graphql_client.dart @@ -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 request( + OperationRequest 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 _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 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.'; + } +} diff --git a/lib/features/mapflow/data/places_repository.dart b/lib/features/mapflow/data/places_repository.dart new file mode 100644 index 0000000..1058e40 --- /dev/null +++ b/lib/features/mapflow/data/places_repository.dart @@ -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> fetchPlaces() async { + final data = await _client.request(GPlacesReq()); + return data.places.map(placeRecommendationFromPlace).toList(); + } + + Future> 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(); + } +} diff --git a/lib/features/mapflow/data/voice_experiences_repository.dart b/lib/features/mapflow/data/voice_experiences_repository.dart new file mode 100644 index 0000000..90e3684 --- /dev/null +++ b/lib/features/mapflow/data/voice_experiences_repository.dart @@ -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 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> fetchVoiceExperiences() async { + final data = await _client.request(GVoiceExperiencesReq()); + return data.voiceExperiences + .map(voiceExperienceDebugFromVoiceExperience) + .toList(); + } +} diff --git a/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart b/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart index adcc6a4..6f043cf 100644 --- a/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart +++ b/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart @@ -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 { - late final Future> _future = MapflowApi() + late final Future> _future = context + .read() .fetchVoiceExperiences(); @override diff --git a/lib/features/mapflow/presentation/mapflow_shell.dart b/lib/features/mapflow/presentation/mapflow_shell.dart index eb756f8..a0ffdcd 100644 --- a/lib/features/mapflow/presentation/mapflow_shell.dart +++ b/lib/features/mapflow/presentation/mapflow_shell.dart @@ -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(); 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 { 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 { return const []; } - return _api.fetchNearbyPlaces( + return context.read().fetchNearbyPlaces( coordinate: coordinate, radiusMeters: _nearbyPlaceRadiusMeters, );