diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..fc50f8f --- /dev/null +++ b/lib/app/app.dart @@ -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 createState() => _MapflowAppState(); +} + +class _MapflowAppState extends State { + 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.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(), + ), + ); + }, + ), + ); + } +} diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart new file mode 100644 index 0000000..8173a20 --- /dev/null +++ b/lib/app/router/app_router.dart @@ -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( + fullscreenDialog: true, + key: state.pageKey, + child: AddExperienceFlow( + coordinate: args.coordinate, + hasTelegramAuth: args.hasTelegramAuth, + ), + transitionsBuilder: (context, animation, _, child) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(animation), + child: child, + ); + }, + ); + }, + ), + GoRoute( + path: 'admin/reviews', + builder: (context, state) => const AdminVoiceExperiencesScreen(), + ), + ], + ), + ], + ); +} diff --git a/lib/app/theme/mapflow_theme.dart b/lib/app/theme/mapflow_theme.dart new file mode 100644 index 0000000..ad6a919 --- /dev/null +++ b/lib/app/theme/mapflow_theme.dart @@ -0,0 +1,124 @@ +import 'package:flex_color_scheme/flex_color_scheme.dart'; +import 'package:flutter/material.dart'; + +@immutable +class MapflowThemeTokens extends ThemeExtension { + 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? 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()!; + } +} diff --git a/lib/state/place_controller.dart b/lib/features/mapflow/application/place_cubit.dart similarity index 51% rename from lib/state/place_controller.dart rename to lib/features/mapflow/application/place_cubit.dart index 820ade2..feb72a6 100644 --- a/lib/state/place_controller.dart +++ b/lib/features/mapflow/application/place_cubit.dart @@ -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.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? 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 { - 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 { + PlaceCubit({required MapflowApi api, required CurrentLocation location}) + : _api = api, + _location = location, + super(const PlaceViewState.loading()); + + final MapflowApi _api; + final CurrentLocation _location; + + Future load() async { + emit(const PlaceViewState.loading()); - @override - Future 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 { 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 { 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 { 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: [], +); diff --git a/lib/api/mapflow_api.dart b/lib/features/mapflow/data/mapflow_api.dart similarity index 98% rename from lib/api/mapflow_api.dart rename to lib/features/mapflow/data/mapflow_api.dart index 35f191a..5a4623c 100644 --- a/lib/api/mapflow_api.dart +++ b/lib/features/mapflow/data/mapflow_api.dart @@ -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({ diff --git a/lib/models/place_models.dart b/lib/features/mapflow/domain/place_models.dart similarity index 100% rename from lib/models/place_models.dart rename to lib/features/mapflow/domain/place_models.dart diff --git a/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart b/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart new file mode 100644 index 0000000..253f134 --- /dev/null +++ b/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart @@ -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 createState() => + _AdminVoiceExperiencesScreenState(); +} + +class _AdminVoiceExperiencesScreenState + extends State { + late final Future> _future = MapflowApi() + .fetchVoiceExperiences(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFFFFBF5), + appBar: AppBar(title: const Text('Отзывы')), + body: FutureBuilder>( + 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 []; + 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 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 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 _selectedAdminTags(Map? analysis) { + final tags = analysis?['tags']; + if (tags is! List) { + return const {}; + } + return tags.whereType().toSet(); +} diff --git a/lib/screens/mapflow_shell.dart b/lib/features/mapflow/presentation/mapflow_shell.dart similarity index 78% rename from lib/screens/mapflow_shell.dart rename to lib/features/mapflow/presentation/mapflow_shell.dart index 547aabc..b432ddf 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/features/mapflow/presentation/mapflow_shell.dart @@ -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( + 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().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(); 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( - 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( - 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 traitCounts; @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = ref.read(placeControllerProvider.notifier); + Widget build(BuildContext context) { + final controller = context.read(); 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 createState() => _AddExperienceFlowState(); + State createState() => _AddExperienceFlowState(); } -class _AddExperienceFlowState extends ConsumerState { +class _AddExperienceFlowState extends State { static const _minimumInformationUnits = 16.0; static const _nearbyPlaceRadiusMeters = 50; @@ -831,9 +829,7 @@ class _AddExperienceFlowState extends ConsumerState { _informationUnits + informationDelta, ); }); - ref - .read(placeControllerProvider.notifier) - .setReviewDuration(_waveController.timeElapsed); + context.read().setReviewDuration(_waveController.timeElapsed); } double _normalizeDbLevel(double currentDb) { @@ -869,7 +865,7 @@ class _AddExperienceFlowState extends ConsumerState { @override Widget build(BuildContext context) { - final controller = ref.read(placeControllerProvider.notifier); + final controller = context.read(); final informationProgress = (_informationUnits / _minimumInformationUnits) .clamp(0.0, 1.0); final content = switch (_step) { @@ -916,7 +912,7 @@ class _AddExperienceFlowState extends ConsumerState { if (!context.mounted) { return; } - Navigator.of(context).pop(); + context.pop(); }, ), }; @@ -937,7 +933,7 @@ class _AddExperienceFlowState extends ConsumerState { 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 { } } -class AdminVoiceExperiencesScreen extends StatefulWidget { - const AdminVoiceExperiencesScreen({super.key}); - - @override - State createState() => - _AdminVoiceExperiencesScreenState(); -} - -class _AdminVoiceExperiencesScreenState - extends State { - late final Future> _future = MapflowApi() - .fetchVoiceExperiences(); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFFFFFBF5), - appBar: AppBar(title: const Text('Отзывы')), - body: FutureBuilder>( - 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 []; - 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 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 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 _selectedAdminTags(Map? analysis) { - final tags = analysis?['tags']; - if (tags is! List) { - return const {}; - } - return tags.whereType().toSet(); -} - class _IntroStep extends StatelessWidget { const _IntroStep({required this.onNext}); diff --git a/lib/main.dart b/lib/main.dart index 98917e6..18f6048 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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.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()); } diff --git a/lib/auth/telegram_login_button.dart b/lib/shared/auth/telegram_login_button.dart similarity index 100% rename from lib/auth/telegram_login_button.dart rename to lib/shared/auth/telegram_login_button.dart diff --git a/lib/auth/telegram_session.dart b/lib/shared/auth/telegram_session.dart similarity index 100% rename from lib/auth/telegram_session.dart rename to lib/shared/auth/telegram_session.dart diff --git a/lib/auth/telegram_session_stub.dart b/lib/shared/auth/telegram_session_stub.dart similarity index 100% rename from lib/auth/telegram_session_stub.dart rename to lib/shared/auth/telegram_session_stub.dart diff --git a/lib/auth/telegram_session_web.dart b/lib/shared/auth/telegram_session_web.dart similarity index 100% rename from lib/auth/telegram_session_web.dart rename to lib/shared/auth/telegram_session_web.dart diff --git a/lib/location/current_location.dart b/lib/shared/location/current_location.dart similarity index 100% rename from lib/location/current_location.dart rename to lib/shared/location/current_location.dart diff --git a/pubspec.lock b/pubspec.lock index 05c1797..8d55dfa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,22 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d - url: "https://pub.dev" - source: hosted - version: "91.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 - url: "https://pub.dev" - source: hosted - version: "8.4.1" archive: dependency: transitive description: @@ -41,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + bloc: + dependency: transitive + description: + name: bloc + sha256: e03b235924e4f509c27b5d6b2f949200e0a91149a9818b4f65eeb56662b75413 + url: "https://pub.dev" + source: hosted + version: "9.2.1" boolean_selector: dependency: transitive description: @@ -57,14 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - cli_config: - dependency: transitive - description: - name: cli_config - sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec - url: "https://pub.dev" - source: hosted - version: "0.2.0" clock: dependency: transitive description: @@ -89,22 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" - url: "https://pub.dev" - source: hosted - version: "1.15.0" cross_file: dependency: transitive description: @@ -185,11 +153,35 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + flex_color_scheme: + dependency: "direct main" + description: + name: flex_color_scheme + sha256: ab854146f201d2d62cc251fd525ef023b84182c4a0bfe4ae4c18ffc505b412d3 + url: "https://pub.dev" + source: hosted + version: "8.4.0" + flex_seed_scheme: + dependency: transitive + description: + name: flex_seed_scheme + sha256: a3183753bbcfc3af106224bff3ab3e1844b73f58062136b7499919f49f3667e7 + url: "https://pub.dev" + source: hosted + version: "4.0.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 + url: "https://pub.dev" + source: hosted + version: "9.1.1" flutter_lints: dependency: "direct dev" description: @@ -206,14 +198,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.3.0" - flutter_riverpod: - dependency: "direct main" - description: - name: flutter_riverpod - sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" - url: "https://pub.dev" - source: hosted - version: "3.3.1" flutter_svg: dependency: "direct main" description: @@ -232,14 +216,6 @@ packages: description: flutter source: sdk version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" geoclue: dependency: transitive description: @@ -312,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" + url: "https://pub.dev" + source: hosted + version: "17.3.0" gsettings: dependency: transitive description: @@ -336,14 +320,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" http_parser: dependency: transitive description: @@ -360,14 +336,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" jni: dependency: transitive description: @@ -452,10 +420,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -464,14 +432,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" native_toolchain_c: dependency: transitive description: @@ -480,14 +440,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.17.6" - node_preamble: + nested: dependency: transitive description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "1.0.0" objective_c: dependency: transitive description: @@ -608,14 +568,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" posix: dependency: transitive description: @@ -632,6 +584,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -712,46 +672,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.7" - riverpod: - dependency: transitive - description: - name: riverpod - sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.dev" - source: hosted - version: "1.1.3" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" simple_sparse_list: dependency: transitive description: @@ -765,22 +685,6 @@ packages: description: flutter source: sdk version: "0.0.0" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.dev" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" - url: "https://pub.dev" - source: hosted - version: "0.10.13" source_span: dependency: transitive description: @@ -797,14 +701,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" - state_notifier: - dependency: transitive - description: - name: state_notifier - sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.dev" - source: hosted - version: "1.0.0" stream_channel: dependency: transitive description: @@ -829,30 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" - url: "https://pub.dev" - source: hosted - version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" - test_core: - dependency: transitive - description: - name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" - url: "https://pub.dev" - source: hosted - version: "0.6.16" + version: "0.7.11" typed_data: dependency: transitive description: @@ -917,14 +797,6 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" waveform_flutter: dependency: "direct main" description: @@ -949,30 +821,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 01eef5e..9fca750 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,7 +34,6 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - flutter_riverpod: ^3.3.1 flutter_map: ^8.3.0 latlong2: ^0.9.1 http: ^1.6.0 @@ -43,6 +42,9 @@ dependencies: flutter_svg: ^2.3.0 waveform_recorder: ^1.8.0 waveform_flutter: ^1.2.0 + go_router: ^17.3.0 + flutter_bloc: ^9.1.1 + flex_color_scheme: ^8.4.0 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index bd9d5df..5df50b1 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,11 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mapflow/main.dart'; +import 'package:mapflow/app/app.dart'; void main() { testWidgets('renders Telegram login before authorization', (tester) async { - await tester.pumpWidget(const ProviderScope(child: MapflowApp())); + await tester.pumpWidget(const MapflowApp()); await tester.pump(); expect(find.text('MapFlow'), findsOneWidget);