This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
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 '../shared/location/current_location.dart';
|
||||
import 'router/app_router.dart';
|
||||
import 'theme/mapflow_theme.dart';
|
||||
|
||||
class MapflowApp extends StatefulWidget {
|
||||
const MapflowApp({super.key});
|
||||
|
||||
@override
|
||||
State<MapflowApp> createState() => _MapflowAppState();
|
||||
}
|
||||
|
||||
class _MapflowAppState extends State<MapflowApp> {
|
||||
late final PlaceCubit _placeCubit;
|
||||
late final GoRouter _router;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_placeCubit = PlaceCubit(api: MapflowApi(), location: CurrentLocation())
|
||||
..load();
|
||||
_router = createAppRouter();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_router.dispose();
|
||||
_placeCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shortcuts =
|
||||
Map<ShortcutActivator, Intent>.of(WidgetsApp.defaultShortcuts)
|
||||
..[const SingleActivator(LogicalKeyboardKey.tab)] =
|
||||
const NextFocusIntent()
|
||||
..[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,
|
||||
},
|
||||
),
|
||||
theme: MapflowTheme.light(),
|
||||
builder: (context, child) {
|
||||
return Shortcuts(
|
||||
shortcuts: shortcuts,
|
||||
child: FocusTraversalGroup(
|
||||
policy: ReadingOrderTraversalPolicy(),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../features/mapflow/presentation/admin_voice_experiences_screen.dart';
|
||||
import '../../features/mapflow/presentation/mapflow_shell.dart';
|
||||
|
||||
class AddExperienceArgs {
|
||||
const AddExperienceArgs({
|
||||
required this.coordinate,
|
||||
required this.hasTelegramAuth,
|
||||
});
|
||||
|
||||
final LatLng? coordinate;
|
||||
final bool hasTelegramAuth;
|
||||
}
|
||||
|
||||
GoRouter createAppRouter() {
|
||||
return GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const MapflowShell(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'experience/new',
|
||||
pageBuilder: (context, state) {
|
||||
final args = state.extra as AddExperienceArgs;
|
||||
return CustomTransitionPage<void>(
|
||||
fullscreenDialog: true,
|
||||
key: state.pageKey,
|
||||
child: AddExperienceFlow(
|
||||
coordinate: args.coordinate,
|
||||
hasTelegramAuth: args.hasTelegramAuth,
|
||||
),
|
||||
transitionsBuilder: (context, animation, _, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'admin/reviews',
|
||||
builder: (context, state) => const AdminVoiceExperiencesScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flex_color_scheme/flex_color_scheme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
class MapflowThemeTokens extends ThemeExtension<MapflowThemeTokens> {
|
||||
const MapflowThemeTokens({
|
||||
required this.mapPanel,
|
||||
required this.mapPanelBorder,
|
||||
required this.mapShadow,
|
||||
required this.danger,
|
||||
required this.onDark,
|
||||
required this.panelRadius,
|
||||
});
|
||||
|
||||
final Color mapPanel;
|
||||
final Color mapPanelBorder;
|
||||
final Color mapShadow;
|
||||
final Color danger;
|
||||
final Color onDark;
|
||||
final double panelRadius;
|
||||
|
||||
@override
|
||||
MapflowThemeTokens copyWith({
|
||||
Color? mapPanel,
|
||||
Color? mapPanelBorder,
|
||||
Color? mapShadow,
|
||||
Color? danger,
|
||||
Color? onDark,
|
||||
double? panelRadius,
|
||||
}) {
|
||||
return MapflowThemeTokens(
|
||||
mapPanel: mapPanel ?? this.mapPanel,
|
||||
mapPanelBorder: mapPanelBorder ?? this.mapPanelBorder,
|
||||
mapShadow: mapShadow ?? this.mapShadow,
|
||||
danger: danger ?? this.danger,
|
||||
onDark: onDark ?? this.onDark,
|
||||
panelRadius: panelRadius ?? this.panelRadius,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
MapflowThemeTokens lerp(
|
||||
covariant ThemeExtension<MapflowThemeTokens>? other,
|
||||
double t,
|
||||
) {
|
||||
if (other is! MapflowThemeTokens) {
|
||||
return this;
|
||||
}
|
||||
return MapflowThemeTokens(
|
||||
mapPanel: Color.lerp(mapPanel, other.mapPanel, t)!,
|
||||
mapPanelBorder: Color.lerp(mapPanelBorder, other.mapPanelBorder, t)!,
|
||||
mapShadow: Color.lerp(mapShadow, other.mapShadow, t)!,
|
||||
danger: Color.lerp(danger, other.danger, t)!,
|
||||
onDark: Color.lerp(onDark, other.onDark, t)!,
|
||||
panelRadius: lerpDouble(panelRadius, other.panelRadius, t),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MapflowTheme {
|
||||
const MapflowTheme._();
|
||||
|
||||
static const tokens = MapflowThemeTokens(
|
||||
mapPanel: Color(0xFFFFFBF5),
|
||||
mapPanelBorder: Color(0xFFE0D8CA),
|
||||
mapShadow: Color(0x33000000),
|
||||
danger: Color(0xFFE11D48),
|
||||
onDark: Colors.white,
|
||||
panelRadius: 8,
|
||||
);
|
||||
|
||||
static ThemeData light() {
|
||||
return FlexThemeData.light(
|
||||
useMaterial3: true,
|
||||
colors: const FlexSchemeColor(
|
||||
primary: Color(0xFF0F766E),
|
||||
primaryContainer: Color(0xFFBFE7DF),
|
||||
secondary: Color(0xFFE11D48),
|
||||
secondaryContainer: Color(0xFFFFD9E1),
|
||||
tertiary: Color(0xFF7C3AED),
|
||||
tertiaryContainer: Color(0xFFE9DDFF),
|
||||
appBarColor: Color(0xFFF7F3EA),
|
||||
error: Color(0xFFB3261E),
|
||||
),
|
||||
scaffoldBackground: const Color(0xFFF7F3EA),
|
||||
surface: tokens.mapPanel,
|
||||
subThemesData: const FlexSubThemesData(
|
||||
defaultRadius: 8,
|
||||
inputDecoratorRadius: 8,
|
||||
cardRadius: 8,
|
||||
chipRadius: 8,
|
||||
filledButtonRadius: 8,
|
||||
),
|
||||
fontFamily: 'SF Pro Display',
|
||||
extensions: const [tokens],
|
||||
).copyWith(
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFFF7F3EA),
|
||||
foregroundColor: Color(0xFF17211D),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: tokens.mapPanel,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||
side: BorderSide(color: tokens.mapPanelBorder),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
shape: StadiumBorder(side: BorderSide(color: tokens.mapPanelBorder)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double lerpDouble(double a, double b, double t) => a + (b - a) * t;
|
||||
|
||||
extension MapflowThemeContext on BuildContext {
|
||||
MapflowThemeTokens get mapflowTokens {
|
||||
return Theme.of(this).extension<MapflowThemeTokens>()!;
|
||||
}
|
||||
}
|
||||
+123
-69
@@ -1,15 +1,14 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../api/mapflow_api.dart';
|
||||
import '../location/current_location.dart';
|
||||
import '../models/place_models.dart';
|
||||
|
||||
final placeControllerProvider =
|
||||
AsyncNotifierProvider<PlaceController, PlaceState>(PlaceController.new);
|
||||
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,
|
||||
@@ -65,9 +64,9 @@ class PlaceState {
|
||||
Object? selectedTrait = _unset,
|
||||
List<PlaceRecommendation>? places,
|
||||
Object? selectedPlaceId = _unset,
|
||||
AppUser? currentUser,
|
||||
Object? currentUser = _unset,
|
||||
bool? hasTelegramAuth,
|
||||
LatLng? userCoordinate,
|
||||
Object? userCoordinate = _unset,
|
||||
VoiceReviewDraft? reviewDraft,
|
||||
}) {
|
||||
return PlaceState(
|
||||
@@ -78,58 +77,82 @@ class PlaceState {
|
||||
selectedPlaceId: identical(selectedPlaceId, _unset)
|
||||
? this.selectedPlaceId
|
||||
: selectedPlaceId as String?,
|
||||
currentUser: currentUser ?? this.currentUser,
|
||||
currentUser: identical(currentUser, _unset)
|
||||
? this.currentUser
|
||||
: currentUser as AppUser?,
|
||||
hasTelegramAuth: hasTelegramAuth ?? this.hasTelegramAuth,
|
||||
userCoordinate: userCoordinate ?? this.userCoordinate,
|
||||
userCoordinate: identical(userCoordinate, _unset)
|
||||
? this.userCoordinate
|
||||
: userCoordinate as LatLng?,
|
||||
reviewDraft: reviewDraft ?? this.reviewDraft,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlaceController extends AsyncNotifier<PlaceState> {
|
||||
final _api = MapflowApi();
|
||||
final _location = CurrentLocation();
|
||||
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());
|
||||
|
||||
@override
|
||||
Future<PlaceState> build() async {
|
||||
if (!_api.hasTelegramAuth) {
|
||||
return const PlaceState(
|
||||
selectedTrait: null,
|
||||
places: [],
|
||||
selectedPlaceId: null,
|
||||
currentUser: null,
|
||||
hasTelegramAuth: false,
|
||||
userCoordinate: null,
|
||||
reviewDraft: VoiceReviewDraft(
|
||||
placeName: '',
|
||||
duration: Duration.zero,
|
||||
extractedTraits: {},
|
||||
evidence: [],
|
||||
),
|
||||
);
|
||||
emit(PlaceViewState.ready(_emptyState(hasTelegramAuth: false)));
|
||||
return;
|
||||
}
|
||||
|
||||
final currentUser = await _api.authenticateTelegram();
|
||||
final userCoordinate = await _location.resolve();
|
||||
final places = await _api.fetchPlaces();
|
||||
return PlaceState(
|
||||
selectedTrait: null,
|
||||
places: places,
|
||||
selectedPlaceId: places.isEmpty ? null : places.first.id,
|
||||
currentUser: currentUser,
|
||||
hasTelegramAuth: _api.hasTelegramAuth,
|
||||
userCoordinate: userCoordinate,
|
||||
reviewDraft: const VoiceReviewDraft(
|
||||
placeName: '',
|
||||
duration: Duration.zero,
|
||||
extractedTraits: {},
|
||||
evidence: [],
|
||||
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 = state.requireValue;
|
||||
final value = _requireReady();
|
||||
PlaceRecommendation? next;
|
||||
for (final place in value.places) {
|
||||
if (place.traits.contains(trait)) {
|
||||
@@ -137,38 +160,46 @@ class PlaceController extends AsyncNotifier<PlaceState> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
state = AsyncData(
|
||||
value.copyWith(selectedTrait: trait, selectedPlaceId: next?.id),
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
value.copyWith(selectedTrait: trait, selectedPlaceId: next?.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void clearTrait() {
|
||||
final value = state.requireValue;
|
||||
final value = _requireReady();
|
||||
final selectedPlaceId = value.places.isEmpty ? null : value.places.first.id;
|
||||
state = AsyncData(
|
||||
value.copyWith(selectedTrait: null, selectedPlaceId: selectedPlaceId),
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
value.copyWith(selectedTrait: null, selectedPlaceId: selectedPlaceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectPlace(String placeId) {
|
||||
final value = state.requireValue;
|
||||
state = AsyncData(value.copyWith(selectedPlaceId: placeId));
|
||||
final value = _requireReady();
|
||||
emit(PlaceViewState.ready(value.copyWith(selectedPlaceId: placeId)));
|
||||
}
|
||||
|
||||
void setReviewPlace(String placeName) {
|
||||
final value = state.requireValue;
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
reviewDraft: value.reviewDraft.copyWith(placeName: placeName),
|
||||
final value = _requireReady();
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
value.copyWith(
|
||||
reviewDraft: value.reviewDraft.copyWith(placeName: placeName),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setReviewDuration(Duration duration) {
|
||||
final value = state.requireValue;
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
reviewDraft: value.reviewDraft.copyWith(duration: duration),
|
||||
final value = _requireReady();
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
value.copyWith(
|
||||
reviewDraft: value.reviewDraft.copyWith(duration: duration),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -179,13 +210,12 @@ class PlaceController extends AsyncNotifier<PlaceState> {
|
||||
required String audioContentBase64,
|
||||
required String audioMimeType,
|
||||
}) async {
|
||||
final value = state.requireValue;
|
||||
final value = _requireReady();
|
||||
if (!value.hasTelegramAuth) {
|
||||
throw StateError('Открой через Telegram, чтобы оставить голос.');
|
||||
}
|
||||
|
||||
final draft = value.reviewDraft;
|
||||
|
||||
await _api.createVoiceExperience(
|
||||
googlePlaceId: place.googlePlaceId,
|
||||
googleName: place.name,
|
||||
@@ -198,17 +228,41 @@ class PlaceController extends AsyncNotifier<PlaceState> {
|
||||
|
||||
final places = await _api.fetchPlaces();
|
||||
final selectedPlace = places.isEmpty ? null : places.first.id;
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
places: places,
|
||||
selectedPlaceId: selectedPlace,
|
||||
reviewDraft: const VoiceReviewDraft(
|
||||
placeName: '',
|
||||
duration: Duration.zero,
|
||||
extractedTraits: {},
|
||||
evidence: [],
|
||||
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: [],
|
||||
);
|
||||
@@ -3,8 +3,8 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../auth/telegram_session.dart' as telegram_auth;
|
||||
import '../models/place_models.dart';
|
||||
import '../../../shared/auth/telegram_session.dart' as telegram_auth;
|
||||
import '../domain/place_models.dart';
|
||||
|
||||
class MapflowApi {
|
||||
MapflowApi({
|
||||
@@ -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();
|
||||
}
|
||||
+47
-374
@@ -3,18 +3,20 @@ import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:latlong2/latlong.dart' hide Path;
|
||||
import 'package:waveform_flutter/waveform_flutter.dart' show Amplitude;
|
||||
import 'package:waveform_recorder/waveform_recorder.dart';
|
||||
|
||||
import '../api/mapflow_api.dart';
|
||||
import '../auth/telegram_login_button.dart';
|
||||
import '../auth/telegram_session.dart' as telegram_session;
|
||||
import '../models/place_models.dart';
|
||||
import '../state/place_controller.dart';
|
||||
import '../../../app/router/app_router.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 '../domain/place_models.dart';
|
||||
|
||||
const _mapboxAccessToken = String.fromEnvironment('MAPBOX_ACCESS_TOKEN');
|
||||
const _mapboxStyle = String.fromEnvironment(
|
||||
@@ -22,26 +24,31 @@ const _mapboxStyle = String.fromEnvironment(
|
||||
defaultValue: 'mapbox/streets-v12',
|
||||
);
|
||||
|
||||
class MapflowShell extends ConsumerWidget {
|
||||
class MapflowShell extends StatelessWidget {
|
||||
const MapflowShell({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncState = ref.watch(placeControllerProvider);
|
||||
|
||||
return asyncState.when(
|
||||
data: (state) => state.hasTelegramAuth
|
||||
? _MapContent(state: state)
|
||||
: _TelegramLoginScreen(
|
||||
onAuthenticated: () => ref.invalidate(placeControllerProvider),
|
||||
),
|
||||
loading: () => const _MapLoading(),
|
||||
error: (error, _) => _MapError(message: error.toString()),
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PlaceCubit, PlaceViewState>(
|
||||
builder: (context, viewState) {
|
||||
return switch (viewState.status) {
|
||||
PlaceLoadStatus.loading => const _MapLoading(),
|
||||
PlaceLoadStatus.failure => _MapError(
|
||||
message: viewState.errorMessage ?? '',
|
||||
),
|
||||
PlaceLoadStatus.ready =>
|
||||
viewState.placeState!.hasTelegramAuth
|
||||
? _MapContent(state: viewState.placeState!)
|
||||
: _TelegramLoginScreen(
|
||||
onAuthenticated: () => context.read<PlaceCubit>().load(),
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapContent extends ConsumerWidget {
|
||||
class _MapContent extends StatelessWidget {
|
||||
const _MapContent({required this.state});
|
||||
|
||||
static const _fallbackCenter = LatLng(10.7718, 106.6982);
|
||||
@@ -49,10 +56,11 @@ class _MapContent extends ConsumerWidget {
|
||||
final PlaceState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
final selected = state.selectedPlace;
|
||||
final userCoordinate = state.userCoordinate;
|
||||
final mapCenter = userCoordinate ?? selected?.coordinate ?? _fallbackCenter;
|
||||
final placeCubit = context.read<PlaceCubit>();
|
||||
final traitCounts = _countPlaceTraits(state.places);
|
||||
final availableTraits = [
|
||||
for (final trait in PlaceTrait.values)
|
||||
@@ -80,9 +88,7 @@ class _MapContent extends ConsumerWidget {
|
||||
point: place.coordinate,
|
||||
child: _PlaceMarker(
|
||||
selected: selected?.id == place.id,
|
||||
onTap: () => ref
|
||||
.read(placeControllerProvider.notifier)
|
||||
.selectPlace(place.id),
|
||||
onTap: () => placeCubit.selectPlace(place.id),
|
||||
),
|
||||
),
|
||||
if (userCoordinate != null)
|
||||
@@ -104,7 +110,7 @@ class _MapContent extends ConsumerWidget {
|
||||
user: state.currentUser,
|
||||
onLogout: () {
|
||||
telegram_session.clearMapflowSession();
|
||||
ref.invalidate(placeControllerProvider);
|
||||
placeCubit.load();
|
||||
telegram_session.reloadApp();
|
||||
},
|
||||
),
|
||||
@@ -115,11 +121,7 @@ class _MapContent extends ConsumerWidget {
|
||||
child: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: _AdminReviewsButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const AdminVoiceExperiencesScreen(),
|
||||
),
|
||||
),
|
||||
onPressed: () => context.push('/admin/reviews'),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -138,9 +140,7 @@ class _MapContent extends ConsumerWidget {
|
||||
),
|
||||
_PlaceCarousel(
|
||||
places: state.recommendations,
|
||||
onSelect: (place) => ref
|
||||
.read(placeControllerProvider.notifier)
|
||||
.selectPlace(place.id),
|
||||
onSelect: (place) => placeCubit.selectPlace(place.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -167,13 +167,11 @@ class _MapContent extends ConsumerWidget {
|
||||
}
|
||||
|
||||
void _openAddFlow(BuildContext context, LatLng? coordinate) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => AddExperienceFlow(
|
||||
coordinate: coordinate,
|
||||
hasTelegramAuth: state.hasTelegramAuth,
|
||||
),
|
||||
context.push(
|
||||
'/experience/new',
|
||||
extra: AddExperienceArgs(
|
||||
coordinate: coordinate,
|
||||
hasTelegramAuth: state.hasTelegramAuth,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -535,7 +533,7 @@ class _MapAttribution extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TraitBar extends ConsumerWidget {
|
||||
class _TraitBar extends StatelessWidget {
|
||||
const _TraitBar({
|
||||
required this.selectedTrait,
|
||||
required this.traits,
|
||||
@@ -547,8 +545,8 @@ class _TraitBar extends ConsumerWidget {
|
||||
final Map<PlaceTrait, int> traitCounts;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = ref.read(placeControllerProvider.notifier);
|
||||
Widget build(BuildContext context) {
|
||||
final controller = context.read<PlaceCubit>();
|
||||
|
||||
return SizedBox(
|
||||
height: 54,
|
||||
@@ -715,7 +713,7 @@ class _PlacePhotoCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class AddExperienceFlow extends ConsumerStatefulWidget {
|
||||
class AddExperienceFlow extends StatefulWidget {
|
||||
const AddExperienceFlow({
|
||||
super.key,
|
||||
required this.coordinate,
|
||||
@@ -726,10 +724,10 @@ class AddExperienceFlow extends ConsumerStatefulWidget {
|
||||
final bool hasTelegramAuth;
|
||||
|
||||
@override
|
||||
ConsumerState<AddExperienceFlow> createState() => _AddExperienceFlowState();
|
||||
State<AddExperienceFlow> createState() => _AddExperienceFlowState();
|
||||
}
|
||||
|
||||
class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
class _AddExperienceFlowState extends State<AddExperienceFlow> {
|
||||
static const _minimumInformationUnits = 16.0;
|
||||
static const _nearbyPlaceRadiusMeters = 50;
|
||||
|
||||
@@ -831,9 +829,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
_informationUnits + informationDelta,
|
||||
);
|
||||
});
|
||||
ref
|
||||
.read(placeControllerProvider.notifier)
|
||||
.setReviewDuration(_waveController.timeElapsed);
|
||||
context.read<PlaceCubit>().setReviewDuration(_waveController.timeElapsed);
|
||||
}
|
||||
|
||||
double _normalizeDbLevel(double currentDb) {
|
||||
@@ -869,7 +865,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = ref.read(placeControllerProvider.notifier);
|
||||
final controller = context.read<PlaceCubit>();
|
||||
final informationProgress = (_informationUnits / _minimumInformationUnits)
|
||||
.clamp(0.0, 1.0);
|
||||
final content = switch (_step) {
|
||||
@@ -916,7 +912,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
};
|
||||
@@ -937,7 +933,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
step: _step,
|
||||
total: 3,
|
||||
dark: true,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
onClose: () => context.pop(),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
@@ -954,329 +950,6 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
class _IntroStep extends StatelessWidget {
|
||||
const _IntroStep({required this.onNext});
|
||||
|
||||
+3
-94
@@ -1,101 +1,10 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'auth/telegram_session.dart' as telegram_session;
|
||||
import 'screens/mapflow_shell.dart';
|
||||
import 'app/app.dart';
|
||||
import 'shared/auth/telegram_session.dart' as telegram_session;
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
telegram_session.configureTelegramWebApp();
|
||||
runApp(const ProviderScope(child: MapflowApp()));
|
||||
}
|
||||
|
||||
class MapflowApp extends StatelessWidget {
|
||||
const MapflowApp({super.key});
|
||||
|
||||
ThemeData _buildTheme() {
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0F766E),
|
||||
primary: const Color(0xFF0F766E),
|
||||
secondary: const Color(0xFFE11D48),
|
||||
surface: const Color(0xFFFFFBF5),
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: const Color(0xFFF7F3EA),
|
||||
fontFamily: 'SF Pro Display',
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFFF7F3EA),
|
||||
foregroundColor: Color(0xFF17211D),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: const Color(0xFFFFFBF5),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: const BorderSide(color: Color(0xFFE0D8CA)),
|
||||
),
|
||||
),
|
||||
chipTheme: const ChipThemeData(
|
||||
shape: StadiumBorder(side: BorderSide(color: Color(0xFFE0D8CA))),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFFFFFFF),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFFD8D0C3)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFFD8D0C3)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shortcuts =
|
||||
Map<ShortcutActivator, Intent>.of(WidgetsApp.defaultShortcuts)
|
||||
..[const SingleActivator(LogicalKeyboardKey.tab)] =
|
||||
const NextFocusIntent()
|
||||
..[const SingleActivator(LogicalKeyboardKey.tab, shift: true)] =
|
||||
const PreviousFocusIntent();
|
||||
|
||||
return MaterialApp(
|
||||
title: 'MapFlow',
|
||||
debugShowCheckedModeBanner: false,
|
||||
scrollBehavior: const MaterialScrollBehavior().copyWith(
|
||||
scrollbars: true,
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
PointerDeviceKind.stylus,
|
||||
},
|
||||
),
|
||||
theme: _buildTheme(),
|
||||
builder: (context, child) {
|
||||
return Shortcuts(
|
||||
shortcuts: shortcuts,
|
||||
child: FocusTraversalGroup(
|
||||
policy: ReadingOrderTraversalPolicy(),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
home: const MapflowShell(),
|
||||
);
|
||||
}
|
||||
runApp(const MapflowApp());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user