Refactor Flutter app architecture
Build and deploy Flutter Web / build (push) Successful in 3m7s

This commit is contained in:
Ruslan Bakiev
2026-06-12 14:25:24 +07:00
parent be41f74b33
commit 5e78a134b0
17 changed files with 821 additions and 751 deletions
@@ -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: [],
);
+387
View File
@@ -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