This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../../shared/location/current_location.dart';
|
||||
import '../data/mapflow_api.dart';
|
||||
import '../domain/place_models.dart';
|
||||
|
||||
const _unset = Object();
|
||||
|
||||
enum PlaceLoadStatus { loading, ready, failure }
|
||||
|
||||
class PlaceState {
|
||||
const PlaceState({
|
||||
required this.selectedTrait,
|
||||
required this.places,
|
||||
required this.selectedPlaceId,
|
||||
required this.currentUser,
|
||||
required this.hasTelegramAuth,
|
||||
required this.userCoordinate,
|
||||
required this.reviewDraft,
|
||||
});
|
||||
|
||||
static final _distance = Distance();
|
||||
|
||||
final PlaceTrait? selectedTrait;
|
||||
final List<PlaceRecommendation> places;
|
||||
final String? selectedPlaceId;
|
||||
final AppUser? currentUser;
|
||||
final bool hasTelegramAuth;
|
||||
final LatLng? userCoordinate;
|
||||
final VoiceReviewDraft reviewDraft;
|
||||
|
||||
List<PlaceRecommendation> get recommendations {
|
||||
final selected = selectedTrait;
|
||||
final matchingPlaces = selected == null
|
||||
? places
|
||||
: places.where((place) => place.traits.contains(selected));
|
||||
final coordinate = userCoordinate;
|
||||
if (coordinate == null) {
|
||||
return matchingPlaces.toList();
|
||||
}
|
||||
|
||||
final ranked = [...matchingPlaces]
|
||||
..sort((a, b) {
|
||||
return _distance(
|
||||
coordinate,
|
||||
a.coordinate,
|
||||
).compareTo(_distance(coordinate, b.coordinate));
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
|
||||
PlaceRecommendation? get selectedPlace {
|
||||
final visiblePlaces = recommendations;
|
||||
for (final place in visiblePlaces) {
|
||||
if (place.id == selectedPlaceId) {
|
||||
return place;
|
||||
}
|
||||
}
|
||||
return visiblePlaces.isEmpty ? null : visiblePlaces.first;
|
||||
}
|
||||
|
||||
PlaceState copyWith({
|
||||
Object? selectedTrait = _unset,
|
||||
List<PlaceRecommendation>? places,
|
||||
Object? selectedPlaceId = _unset,
|
||||
Object? currentUser = _unset,
|
||||
bool? hasTelegramAuth,
|
||||
Object? userCoordinate = _unset,
|
||||
VoiceReviewDraft? reviewDraft,
|
||||
}) {
|
||||
return PlaceState(
|
||||
selectedTrait: identical(selectedTrait, _unset)
|
||||
? this.selectedTrait
|
||||
: selectedTrait as PlaceTrait?,
|
||||
places: places ?? this.places,
|
||||
selectedPlaceId: identical(selectedPlaceId, _unset)
|
||||
? this.selectedPlaceId
|
||||
: selectedPlaceId as String?,
|
||||
currentUser: identical(currentUser, _unset)
|
||||
? this.currentUser
|
||||
: currentUser as AppUser?,
|
||||
hasTelegramAuth: hasTelegramAuth ?? this.hasTelegramAuth,
|
||||
userCoordinate: identical(userCoordinate, _unset)
|
||||
? this.userCoordinate
|
||||
: userCoordinate as LatLng?,
|
||||
reviewDraft: reviewDraft ?? this.reviewDraft,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlaceViewState {
|
||||
const PlaceViewState({
|
||||
required this.status,
|
||||
required this.placeState,
|
||||
required this.errorMessage,
|
||||
});
|
||||
|
||||
const PlaceViewState.loading()
|
||||
: status = PlaceLoadStatus.loading,
|
||||
placeState = null,
|
||||
errorMessage = null;
|
||||
|
||||
const PlaceViewState.ready(PlaceState state)
|
||||
: status = PlaceLoadStatus.ready,
|
||||
placeState = state,
|
||||
errorMessage = null;
|
||||
|
||||
const PlaceViewState.failure(String message)
|
||||
: status = PlaceLoadStatus.failure,
|
||||
placeState = null,
|
||||
errorMessage = message;
|
||||
|
||||
final PlaceLoadStatus status;
|
||||
final PlaceState? placeState;
|
||||
final String? errorMessage;
|
||||
}
|
||||
|
||||
class PlaceCubit extends Cubit<PlaceViewState> {
|
||||
PlaceCubit({required MapflowApi api, required CurrentLocation location})
|
||||
: _api = api,
|
||||
_location = location,
|
||||
super(const PlaceViewState.loading());
|
||||
|
||||
final MapflowApi _api;
|
||||
final CurrentLocation _location;
|
||||
|
||||
Future<void> load() async {
|
||||
emit(const PlaceViewState.loading());
|
||||
|
||||
if (!_api.hasTelegramAuth) {
|
||||
emit(PlaceViewState.ready(_emptyState(hasTelegramAuth: false)));
|
||||
return;
|
||||
}
|
||||
|
||||
final currentUser = await _api.authenticateTelegram();
|
||||
final userCoordinate = await _location.resolve();
|
||||
final places = await _api.fetchPlaces();
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
PlaceState(
|
||||
selectedTrait: null,
|
||||
places: places,
|
||||
selectedPlaceId: places.isEmpty ? null : places.first.id,
|
||||
currentUser: currentUser,
|
||||
hasTelegramAuth: _api.hasTelegramAuth,
|
||||
userCoordinate: userCoordinate,
|
||||
reviewDraft: _emptyDraft,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectTrait(PlaceTrait trait) {
|
||||
final value = _requireReady();
|
||||
PlaceRecommendation? next;
|
||||
for (final place in value.places) {
|
||||
if (place.traits.contains(trait)) {
|
||||
next = place;
|
||||
break;
|
||||
}
|
||||
}
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
value.copyWith(selectedTrait: trait, selectedPlaceId: next?.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void clearTrait() {
|
||||
final value = _requireReady();
|
||||
final selectedPlaceId = value.places.isEmpty ? null : value.places.first.id;
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
value.copyWith(selectedTrait: null, selectedPlaceId: selectedPlaceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectPlace(String placeId) {
|
||||
final value = _requireReady();
|
||||
emit(PlaceViewState.ready(value.copyWith(selectedPlaceId: placeId)));
|
||||
}
|
||||
|
||||
void setReviewPlace(String placeName) {
|
||||
final value = _requireReady();
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
value.copyWith(
|
||||
reviewDraft: value.reviewDraft.copyWith(placeName: placeName),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setReviewDuration(Duration duration) {
|
||||
final value = _requireReady();
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
value.copyWith(
|
||||
reviewDraft: value.reviewDraft.copyWith(duration: duration),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> publishReview({
|
||||
required PlaceRecommendation place,
|
||||
required String audioObjectKey,
|
||||
required String audioContentBase64,
|
||||
required String audioMimeType,
|
||||
}) async {
|
||||
final value = _requireReady();
|
||||
if (!value.hasTelegramAuth) {
|
||||
throw StateError('Открой через Telegram, чтобы оставить голос.');
|
||||
}
|
||||
|
||||
final draft = value.reviewDraft;
|
||||
await _api.createVoiceExperience(
|
||||
googlePlaceId: place.googlePlaceId,
|
||||
googleName: place.name,
|
||||
coordinate: place.coordinate,
|
||||
durationSeconds: draft.duration.inSeconds,
|
||||
audioObjectKey: audioObjectKey,
|
||||
audioContentBase64: audioContentBase64,
|
||||
audioMimeType: audioMimeType,
|
||||
);
|
||||
|
||||
final places = await _api.fetchPlaces();
|
||||
final selectedPlace = places.isEmpty ? null : places.first.id;
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
value.copyWith(
|
||||
places: places,
|
||||
selectedPlaceId: selectedPlace,
|
||||
reviewDraft: _emptyDraft,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PlaceState _requireReady() {
|
||||
final value = state.placeState;
|
||||
if (value == null) {
|
||||
throw StateError('Place state is not ready.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static PlaceState _emptyState({required bool hasTelegramAuth}) {
|
||||
return PlaceState(
|
||||
selectedTrait: null,
|
||||
places: const [],
|
||||
selectedPlaceId: null,
|
||||
currentUser: null,
|
||||
hasTelegramAuth: hasTelegramAuth,
|
||||
userCoordinate: null,
|
||||
reviewDraft: _emptyDraft,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _emptyDraft = VoiceReviewDraft(
|
||||
placeName: '',
|
||||
duration: Duration.zero,
|
||||
extractedTraits: {},
|
||||
evidence: [],
|
||||
);
|
||||
@@ -0,0 +1,387 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../../shared/auth/telegram_session.dart' as telegram_auth;
|
||||
import '../domain/place_models.dart';
|
||||
|
||||
class MapflowApi {
|
||||
MapflowApi({
|
||||
http.Client? client,
|
||||
String? telegramInitData,
|
||||
String? telegramLoginData,
|
||||
String? mapflowSessionToken,
|
||||
String endpoint = const String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: '/graphql',
|
||||
),
|
||||
}) : _client = client ?? http.Client(),
|
||||
_telegramInitData = telegramInitData ?? telegram_auth.telegramInitData(),
|
||||
_telegramLoginData =
|
||||
telegramLoginData ?? telegram_auth.telegramLoginData(),
|
||||
_mapflowSessionToken =
|
||||
mapflowSessionToken ?? telegram_auth.mapflowSessionToken(),
|
||||
_endpoint = Uri.base.resolve(endpoint);
|
||||
|
||||
final http.Client _client;
|
||||
final String _telegramInitData;
|
||||
final String _telegramLoginData;
|
||||
final String _mapflowSessionToken;
|
||||
final Uri _endpoint;
|
||||
|
||||
bool get hasTelegramAuth =>
|
||||
_telegramInitData.isNotEmpty ||
|
||||
_telegramLoginData.isNotEmpty ||
|
||||
_mapflowSessionToken.isNotEmpty;
|
||||
|
||||
Future<AppUser?> authenticateTelegram() async {
|
||||
if (!hasTelegramAuth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_mapflowSessionToken.isNotEmpty) {
|
||||
final data = await _graphql('''
|
||||
query Me {
|
||||
me {
|
||||
id
|
||||
telegramId
|
||||
username
|
||||
firstName
|
||||
lastName
|
||||
photoUrl
|
||||
languageCode
|
||||
isAdmin
|
||||
}
|
||||
}
|
||||
''');
|
||||
return AppUser.fromJson(data['me'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
if (_telegramLoginData.isNotEmpty) {
|
||||
final loginData = jsonDecode(_telegramLoginData) as Map<String, dynamic>;
|
||||
final data = await _graphql(
|
||||
'''
|
||||
mutation AuthenticateTelegramLogin(
|
||||
\$input: AuthenticateTelegramLoginInput!
|
||||
) {
|
||||
authenticateTelegramLogin(input: \$input) {
|
||||
user {
|
||||
id
|
||||
telegramId
|
||||
username
|
||||
firstName
|
||||
lastName
|
||||
photoUrl
|
||||
languageCode
|
||||
isAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
''',
|
||||
variables: {'input': loginData},
|
||||
);
|
||||
|
||||
final payload = data['authenticateTelegramLogin'] as Map<String, dynamic>;
|
||||
final user = payload['user'] as Map<String, dynamic>;
|
||||
return AppUser.fromJson(user);
|
||||
}
|
||||
|
||||
final data = await _graphql(
|
||||
'''
|
||||
mutation AuthenticateTelegram(\$input: AuthenticateTelegramInput!) {
|
||||
authenticateTelegram(input: \$input) {
|
||||
user {
|
||||
id
|
||||
telegramId
|
||||
username
|
||||
firstName
|
||||
lastName
|
||||
photoUrl
|
||||
languageCode
|
||||
isAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
''',
|
||||
variables: {
|
||||
'input': {'initData': _telegramInitData},
|
||||
},
|
||||
);
|
||||
|
||||
final payload = data['authenticateTelegram'] as Map<String, dynamic>;
|
||||
final user = payload['user'] as Map<String, dynamic>;
|
||||
return AppUser.fromJson(user);
|
||||
}
|
||||
|
||||
Future<TelegramBotLogin> startTelegramBotLogin() async {
|
||||
final data = await _graphql('''
|
||||
mutation StartTelegramBotLogin {
|
||||
startTelegramBotLogin {
|
||||
token
|
||||
botUrl
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
''');
|
||||
return TelegramBotLogin.fromJson(
|
||||
data['startTelegramBotLogin'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TelegramBotLoginSession> completeTelegramBotLogin(String token) async {
|
||||
final data = await _graphql(
|
||||
'''
|
||||
mutation CompleteTelegramBotLogin(\$token: String!) {
|
||||
completeTelegramBotLogin(token: \$token) {
|
||||
sessionToken
|
||||
user {
|
||||
id
|
||||
telegramId
|
||||
username
|
||||
firstName
|
||||
lastName
|
||||
photoUrl
|
||||
languageCode
|
||||
isAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
''',
|
||||
variables: {'token': token},
|
||||
);
|
||||
return TelegramBotLoginSession.fromJson(
|
||||
data['completeTelegramBotLogin'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<PlaceRecommendation>> fetchPlaces() async {
|
||||
final data = await _graphql('''
|
||||
query Places {
|
||||
places {
|
||||
id
|
||||
googlePlaceId
|
||||
name
|
||||
latitude
|
||||
longitude
|
||||
googlePrimaryType
|
||||
googleTypes
|
||||
experiences {
|
||||
id
|
||||
status
|
||||
analysis
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
|
||||
final places = data['places'] as List<dynamic>;
|
||||
return places.map((item) {
|
||||
final place = item as Map<String, dynamic>;
|
||||
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 num).toDouble(),
|
||||
(place['longitude'] as num).toDouble(),
|
||||
),
|
||||
traits: _traitsFromExperiences(place['experiences'] as List<dynamic>),
|
||||
googlePrimaryType: place['googlePrimaryType'] as String?,
|
||||
googleTypes: (place['googleTypes'] as List<dynamic>)
|
||||
.map((type) => type as String)
|
||||
.toList(),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<List<PlaceRecommendation>> fetchNearbyPlaces({
|
||||
required LatLng coordinate,
|
||||
required int radiusMeters,
|
||||
}) async {
|
||||
final data = await _graphql(
|
||||
'''
|
||||
query NearbyPlaces(\$input: NearbyPlacesInput!) {
|
||||
nearbyPlaces(input: \$input) {
|
||||
id
|
||||
googlePlaceId
|
||||
name
|
||||
latitude
|
||||
longitude
|
||||
googlePrimaryType
|
||||
googleTypes
|
||||
experiences {
|
||||
id
|
||||
status
|
||||
analysis
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
''',
|
||||
variables: {
|
||||
'input': {
|
||||
'latitude': coordinate.latitude,
|
||||
'longitude': coordinate.longitude,
|
||||
'radiusMeters': radiusMeters,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
final places = data['nearbyPlaces'] as List<dynamic>;
|
||||
return places.map((item) {
|
||||
final place = item as Map<String, dynamic>;
|
||||
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 num).toDouble(),
|
||||
(place['longitude'] as num).toDouble(),
|
||||
),
|
||||
traits: _traitsFromExperiences(place['experiences'] as List<dynamic>),
|
||||
googlePrimaryType: place['googlePrimaryType'] as String?,
|
||||
googleTypes: (place['googleTypes'] as List<dynamic>)
|
||||
.map((type) => type as String)
|
||||
.toList(),
|
||||
);
|
||||
}).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 _graphql(
|
||||
'''
|
||||
mutation CreateVoiceExperience(\$input: CreateVoiceExperienceInput!) {
|
||||
createVoiceExperience(input: \$input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
''',
|
||||
variables: {
|
||||
'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 _graphql('''
|
||||
query VoiceExperiences {
|
||||
voiceExperiences {
|
||||
id
|
||||
status
|
||||
durationSeconds
|
||||
transcript
|
||||
analysis
|
||||
createdAt
|
||||
place {
|
||||
name
|
||||
}
|
||||
user {
|
||||
telegramId
|
||||
username
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
|
||||
final experiences = data['voiceExperiences'] as List<dynamic>;
|
||||
return experiences
|
||||
.map(
|
||||
(item) => VoiceExperienceDebug.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _graphql(
|
||||
String query, {
|
||||
Map<String, dynamic>? variables,
|
||||
}) async {
|
||||
final response = await _client.post(
|
||||
_endpoint,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
if (_telegramInitData.isNotEmpty)
|
||||
'x-telegram-init-data': _telegramInitData,
|
||||
if (_telegramLoginData.isNotEmpty)
|
||||
'x-telegram-login-data': _telegramLoginData,
|
||||
if (_mapflowSessionToken.isNotEmpty)
|
||||
'x-mapflow-session-token': _mapflowSessionToken,
|
||||
},
|
||||
body: jsonEncode({'query': query, 'variables': variables ?? {}}),
|
||||
);
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw StateError('GraphQL request failed with ${response.statusCode}.');
|
||||
}
|
||||
|
||||
final payload = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final errors = payload['errors'];
|
||||
if (errors is List && errors.isNotEmpty) {
|
||||
throw StateError(jsonEncode(errors));
|
||||
}
|
||||
|
||||
return payload['data'] as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Set<PlaceTrait> _traitsFromExperiences(List<dynamic> experiences) {
|
||||
final traits = <PlaceTrait>{};
|
||||
for (final item in experiences) {
|
||||
final experience = item as Map<String, dynamic>;
|
||||
final analysis = experience['analysis'];
|
||||
if (analysis is! Map<String, dynamic>) {
|
||||
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;
|
||||
}
|
||||
|
||||
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,222 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class AppUser {
|
||||
const AppUser({
|
||||
required this.id,
|
||||
required this.telegramId,
|
||||
required this.username,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.photoUrl,
|
||||
required this.languageCode,
|
||||
required this.isAdmin,
|
||||
});
|
||||
|
||||
factory AppUser.fromJson(Map<String, dynamic> json) {
|
||||
return AppUser(
|
||||
id: json['id'] as String,
|
||||
telegramId: json['telegramId'] as String,
|
||||
username: json['username'] as String?,
|
||||
firstName: json['firstName'] as String?,
|
||||
lastName: json['lastName'] as String?,
|
||||
photoUrl: json['photoUrl'] as String?,
|
||||
languageCode: json['languageCode'] as String?,
|
||||
isAdmin: json['isAdmin'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String telegramId;
|
||||
final String? username;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? photoUrl;
|
||||
final String? languageCode;
|
||||
final bool isAdmin;
|
||||
}
|
||||
|
||||
class VoiceExperienceDebug {
|
||||
const VoiceExperienceDebug({
|
||||
required this.id,
|
||||
required this.placeName,
|
||||
required this.userName,
|
||||
required this.status,
|
||||
required this.durationSeconds,
|
||||
required this.transcript,
|
||||
required this.analysis,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory VoiceExperienceDebug.fromJson(Map<String, dynamic> json) {
|
||||
final place = json['place'] as Map<String, dynamic>;
|
||||
final user = json['user'] as Map<String, dynamic>?;
|
||||
final firstName = user?['firstName'] as String?;
|
||||
final username = user?['username'] as String?;
|
||||
final telegramId = user?['telegramId'] as String?;
|
||||
return VoiceExperienceDebug(
|
||||
id: json['id'] as String,
|
||||
placeName: place['name'] as String,
|
||||
userName: firstName?.trim().isNotEmpty == true
|
||||
? firstName!
|
||||
: username?.trim().isNotEmpty == true
|
||||
? '@$username'
|
||||
: telegramId ?? '',
|
||||
status: json['status'] as String,
|
||||
durationSeconds: json['durationSeconds'] as int,
|
||||
transcript: json['transcript'] as String?,
|
||||
analysis: json['analysis'] as Map<String, dynamic>?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String placeName;
|
||||
final String userName;
|
||||
final String status;
|
||||
final int durationSeconds;
|
||||
final String? transcript;
|
||||
final Map<String, dynamic>? analysis;
|
||||
final DateTime createdAt;
|
||||
}
|
||||
|
||||
enum PlaceTrait {
|
||||
calm,
|
||||
dynamic,
|
||||
intimate,
|
||||
open,
|
||||
solo,
|
||||
group,
|
||||
reset,
|
||||
impress,
|
||||
transit,
|
||||
clean,
|
||||
expressive,
|
||||
}
|
||||
|
||||
extension PlaceTraitText on PlaceTrait {
|
||||
String get label {
|
||||
return switch (this) {
|
||||
PlaceTrait.calm => 'спокойное',
|
||||
PlaceTrait.dynamic => 'живое',
|
||||
PlaceTrait.intimate => 'камерное',
|
||||
PlaceTrait.open => 'открытое',
|
||||
PlaceTrait.solo => 'для себя',
|
||||
PlaceTrait.group => 'для компании',
|
||||
PlaceTrait.reset => 'выдохнуть',
|
||||
PlaceTrait.impress => 'впечатлить',
|
||||
PlaceTrait.transit => 'транзитное',
|
||||
PlaceTrait.clean => 'чистое',
|
||||
PlaceTrait.expressive => 'выразительное',
|
||||
};
|
||||
}
|
||||
|
||||
IconData get icon {
|
||||
return switch (this) {
|
||||
PlaceTrait.calm => Icons.air_outlined,
|
||||
PlaceTrait.dynamic => Icons.bolt_outlined,
|
||||
PlaceTrait.intimate => Icons.lock_outline,
|
||||
PlaceTrait.open => Icons.public_outlined,
|
||||
PlaceTrait.solo => Icons.person_outline,
|
||||
PlaceTrait.group => Icons.forum_outlined,
|
||||
PlaceTrait.reset => Icons.spa_outlined,
|
||||
PlaceTrait.impress => Icons.diamond_outlined,
|
||||
PlaceTrait.transit => Icons.near_me_outlined,
|
||||
PlaceTrait.clean => Icons.wb_sunny_outlined,
|
||||
PlaceTrait.expressive => Icons.auto_awesome_outlined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class PlaceRecommendation {
|
||||
const PlaceRecommendation({
|
||||
required this.id,
|
||||
required this.googlePlaceId,
|
||||
required this.name,
|
||||
required this.area,
|
||||
required this.photoUrls,
|
||||
required this.coordinate,
|
||||
required this.traits,
|
||||
required this.googlePrimaryType,
|
||||
required this.googleTypes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String googlePlaceId;
|
||||
final String name;
|
||||
final String area;
|
||||
final List<String> photoUrls;
|
||||
final LatLng coordinate;
|
||||
final Set<PlaceTrait> traits;
|
||||
final String? googlePrimaryType;
|
||||
final List<String> googleTypes;
|
||||
|
||||
String get coverPhotoUrl => photoUrls.first;
|
||||
}
|
||||
|
||||
class VoiceReviewDraft {
|
||||
const VoiceReviewDraft({
|
||||
required this.placeName,
|
||||
required this.duration,
|
||||
required this.extractedTraits,
|
||||
required this.evidence,
|
||||
});
|
||||
|
||||
final String placeName;
|
||||
final Duration duration;
|
||||
final Set<PlaceTrait> extractedTraits;
|
||||
final List<String> evidence;
|
||||
|
||||
bool get isLongEnough => duration.inSeconds >= 30;
|
||||
|
||||
VoiceReviewDraft copyWith({
|
||||
String? placeName,
|
||||
Duration? duration,
|
||||
Set<PlaceTrait>? extractedTraits,
|
||||
List<String>? evidence,
|
||||
}) {
|
||||
return VoiceReviewDraft(
|
||||
placeName: placeName ?? this.placeName,
|
||||
duration: duration ?? this.duration,
|
||||
extractedTraits: extractedTraits ?? this.extractedTraits,
|
||||
evidence: evidence ?? this.evidence,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TelegramBotLogin {
|
||||
const TelegramBotLogin({
|
||||
required this.token,
|
||||
required this.botUrl,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
factory TelegramBotLogin.fromJson(Map<String, dynamic> json) {
|
||||
return TelegramBotLogin(
|
||||
token: json['token'] as String,
|
||||
botUrl: json['botUrl'] as String,
|
||||
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
final String token;
|
||||
final String botUrl;
|
||||
final DateTime expiresAt;
|
||||
}
|
||||
|
||||
class TelegramBotLoginSession {
|
||||
const TelegramBotLoginSession({
|
||||
required this.sessionToken,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
factory TelegramBotLoginSession.fromJson(Map<String, dynamic> json) {
|
||||
return TelegramBotLoginSession(
|
||||
sessionToken: json['sessionToken'] as String,
|
||||
user: AppUser.fromJson(json['user'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
final String sessionToken;
|
||||
final AppUser user;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/mapflow_api.dart';
|
||||
import '../domain/place_models.dart';
|
||||
|
||||
class AdminVoiceExperiencesScreen extends StatefulWidget {
|
||||
const AdminVoiceExperiencesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AdminVoiceExperiencesScreen> createState() =>
|
||||
_AdminVoiceExperiencesScreenState();
|
||||
}
|
||||
|
||||
class _AdminVoiceExperiencesScreenState
|
||||
extends State<AdminVoiceExperiencesScreen> {
|
||||
late final Future<List<VoiceExperienceDebug>> _future = MapflowApi()
|
||||
.fetchVoiceExperiences();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFFFFBF5),
|
||||
appBar: AppBar(title: const Text('Отзывы')),
|
||||
body: FutureBuilder<List<VoiceExperienceDebug>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text(snapshot.error.toString()));
|
||||
}
|
||||
|
||||
final reviews = snapshot.data ?? const <VoiceExperienceDebug>[];
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: reviews.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
return _AdminVoiceExperienceRow(review: reviews[index]);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminVoiceExperienceRow extends StatelessWidget {
|
||||
const _AdminVoiceExperienceRow({required this.review});
|
||||
|
||||
final VoiceExperienceDebug review;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedTags = _selectedAdminTags(review.analysis);
|
||||
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
review.placeName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w900),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
review.status,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${review.userName} · ${review.durationSeconds}s',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFF6B6258)),
|
||||
),
|
||||
if (review.transcript?.trim().isNotEmpty == true) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
review.transcript!,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
_AdminOntologySnowflake(selectedTags: selectedTags),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminOntologySnowflake extends StatelessWidget {
|
||||
const _AdminOntologySnowflake({required this.selectedTags});
|
||||
|
||||
final Set<String> selectedTags;
|
||||
|
||||
static const _axes = [
|
||||
_AdminOntologyAxis(
|
||||
id: 'energy',
|
||||
label: 'энергия',
|
||||
angle: -math.pi / 2,
|
||||
leaves: [
|
||||
_AdminOntologyLeaf('calm', 'спокойное', -0.22),
|
||||
_AdminOntologyLeaf('dynamic', 'живое', 0.22),
|
||||
],
|
||||
),
|
||||
_AdminOntologyAxis(
|
||||
id: 'privacy',
|
||||
label: 'приватность',
|
||||
angle: -math.pi / 2 + math.pi * 2 / 5,
|
||||
leaves: [
|
||||
_AdminOntologyLeaf('intimate', 'камерное', -0.2),
|
||||
_AdminOntologyLeaf('open', 'открытое', 0.2),
|
||||
],
|
||||
),
|
||||
_AdminOntologyAxis(
|
||||
id: 'function',
|
||||
label: 'сценарий',
|
||||
angle: -math.pi / 2 + math.pi * 4 / 5,
|
||||
leaves: [
|
||||
_AdminOntologyLeaf('reset', 'выдохнуть', -0.25),
|
||||
_AdminOntologyLeaf('impress', 'впечатлить', 0),
|
||||
_AdminOntologyLeaf('transit', 'транзитное', 0.25),
|
||||
],
|
||||
),
|
||||
_AdminOntologyAxis(
|
||||
id: 'aesthetic',
|
||||
label: 'образ',
|
||||
angle: -math.pi / 2 + math.pi * 6 / 5,
|
||||
leaves: [
|
||||
_AdminOntologyLeaf('clean', 'чистое', -0.2),
|
||||
_AdminOntologyLeaf('expressive', 'выразительное', 0.2),
|
||||
],
|
||||
),
|
||||
_AdminOntologyAxis(
|
||||
id: 'sociality',
|
||||
label: 'социальность',
|
||||
angle: -math.pi / 2 + math.pi * 8 / 5,
|
||||
leaves: [
|
||||
_AdminOntologyLeaf('solo', 'для себя', -0.2),
|
||||
_AdminOntologyLeaf('group', 'для компании', 0.2),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 300,
|
||||
width: double.infinity,
|
||||
child: CustomPaint(
|
||||
painter: _AdminOntologySnowflakePainter(
|
||||
axes: _axes,
|
||||
selectedTags: selectedTags,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminOntologySnowflakePainter extends CustomPainter {
|
||||
const _AdminOntologySnowflakePainter({
|
||||
required this.axes,
|
||||
required this.selectedTags,
|
||||
});
|
||||
|
||||
final List<_AdminOntologyAxis> axes;
|
||||
final Set<String> selectedTags;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = math.min(size.width, size.height);
|
||||
final axisRadius = radius * 0.25;
|
||||
final leafRadius = radius * 0.43;
|
||||
|
||||
final baseLine = Paint()
|
||||
..color = const Color(0xFFE6DDD2)
|
||||
..strokeWidth = 1.3
|
||||
..style = PaintingStyle.stroke;
|
||||
final selectedLine = Paint()
|
||||
..color = const Color(0xFFE11D48)
|
||||
..strokeWidth = 2.2
|
||||
..strokeCap = StrokeCap.round
|
||||
..style = PaintingStyle.stroke;
|
||||
final node = Paint()..color = const Color(0xFFDED3C7);
|
||||
final selectedNode = Paint()..color = const Color(0xFFE11D48);
|
||||
final centerNode = Paint()..color = const Color(0xFF241B18);
|
||||
|
||||
canvas.drawCircle(center, 5, centerNode);
|
||||
_drawLabel(canvas, size, center + const Offset(0, 14), 'место', true);
|
||||
|
||||
for (final axis in axes) {
|
||||
final axisOffset = Offset(math.cos(axis.angle), math.sin(axis.angle));
|
||||
final axisPoint = center + axisOffset * axisRadius;
|
||||
final hasSelectedLeaf = axis.leaves.any(
|
||||
(leaf) => selectedTags.contains('${axis.id}:${leaf.id}'),
|
||||
);
|
||||
|
||||
canvas.drawLine(
|
||||
center,
|
||||
axisPoint,
|
||||
hasSelectedLeaf ? selectedLine : baseLine,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
axisPoint,
|
||||
hasSelectedLeaf ? 5.5 : 4.5,
|
||||
hasSelectedLeaf ? selectedNode : node,
|
||||
);
|
||||
_drawLabel(
|
||||
canvas,
|
||||
size,
|
||||
axisPoint + axisOffset * 18,
|
||||
axis.label,
|
||||
hasSelectedLeaf,
|
||||
fontSize: 11,
|
||||
);
|
||||
|
||||
for (final leaf in axis.leaves) {
|
||||
final leafAngle = axis.angle + leaf.angleOffset;
|
||||
final leafOffset = Offset(math.cos(leafAngle), math.sin(leafAngle));
|
||||
final leafPoint = center + leafOffset * leafRadius;
|
||||
final tag = '${axis.id}:${leaf.id}';
|
||||
final selected = selectedTags.contains(tag);
|
||||
|
||||
canvas.drawLine(
|
||||
axisPoint,
|
||||
leafPoint,
|
||||
selected ? selectedLine : baseLine,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
leafPoint,
|
||||
selected ? 8 : 5.5,
|
||||
selected ? selectedNode : node,
|
||||
);
|
||||
_drawLabel(
|
||||
canvas,
|
||||
size,
|
||||
leafPoint + leafOffset * 20,
|
||||
leaf.label,
|
||||
selected,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawLabel(
|
||||
Canvas canvas,
|
||||
Size size,
|
||||
Offset anchor,
|
||||
String label,
|
||||
bool selected, {
|
||||
double fontSize = 12,
|
||||
}) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: label,
|
||||
style: TextStyle(
|
||||
color: selected ? const Color(0xFFE11D48) : const Color(0xFF746A60),
|
||||
fontSize: fontSize,
|
||||
fontWeight: selected ? FontWeight.w900 : FontWeight.w700,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
)..layout(maxWidth: 86);
|
||||
final dx = (anchor.dx - painter.width / 2).clamp(
|
||||
0.0,
|
||||
size.width - painter.width,
|
||||
);
|
||||
final dy = (anchor.dy - painter.height / 2).clamp(
|
||||
0.0,
|
||||
size.height - painter.height,
|
||||
);
|
||||
painter.paint(canvas, Offset(dx, dy));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _AdminOntologySnowflakePainter oldDelegate) {
|
||||
return oldDelegate.selectedTags != selectedTags;
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminOntologyAxis {
|
||||
const _AdminOntologyAxis({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.angle,
|
||||
required this.leaves,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String label;
|
||||
final double angle;
|
||||
final List<_AdminOntologyLeaf> leaves;
|
||||
}
|
||||
|
||||
class _AdminOntologyLeaf {
|
||||
const _AdminOntologyLeaf(this.id, this.label, this.angleOffset);
|
||||
|
||||
final String id;
|
||||
final String label;
|
||||
final double angleOffset;
|
||||
}
|
||||
|
||||
Set<String> _selectedAdminTags(Map<String, dynamic>? analysis) {
|
||||
final tags = analysis?['tags'];
|
||||
if (tags is! List) {
|
||||
return const {};
|
||||
}
|
||||
return tags.whereType<String>().toSet();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user