Split MapFlow data repositories
Build and deploy Flutter Web / build (push) Successful in 2m16s

This commit is contained in:
Ruslan Bakiev
2026-06-12 15:14:01 +07:00
parent aaaf60ab05
commit 8aa2a43cc7
10 changed files with 582 additions and 393 deletions
+51 -26
View File
@@ -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.');
}
}
-347
View File
@@ -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,
);