diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index e4fba4a..817acad 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -28,7 +28,7 @@ jobs: --tag "$IMAGE_SHA" \ --tag "$IMAGE_LATEST" \ --build-arg MAPBOX_ACCESS_TOKEN="${{ secrets.MAPBOX_ACCESS_TOKEN }}" \ - --build-arg MAPBOX_STYLE="mapbox/streets-v12" \ + --build-arg MAPBOX_STYLE="mapbox/standard" \ --build-arg TELEGRAM_BOT_USERNAME="carfteebot" \ --build-arg BUILD_VERSION="${GITHUB_SHA}" \ . && diff --git a/Dockerfile b/Dockerfile index 959ec22..a66b216 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM ghcr.io/cirruslabs/flutter:stable AS build WORKDIR /app ARG MAPBOX_ACCESS_TOKEN="" -ARG MAPBOX_STYLE="mapbox/streets-v12" +ARG MAPBOX_STYLE="mapbox/standard" ARG TELEGRAM_BOT_USERNAME="carfteebot" ARG BUILD_VERSION="local" COPY pubspec.* ./ diff --git a/lib/features/mapflow/application/place_cubit.dart b/lib/features/mapflow/application/place_cubit.dart index 4be20fa..1361073 100644 --- a/lib/features/mapflow/application/place_cubit.dart +++ b/lib/features/mapflow/application/place_cubit.dart @@ -65,7 +65,7 @@ class PlaceState { return place; } } - return visiblePlaces.isEmpty ? null : visiblePlaces.first; + return null; } PlaceState copyWith({ @@ -165,7 +165,7 @@ class PlaceCubit extends Cubit { PlaceState( selectedTrait: null, places: places, - selectedPlaceId: places.isEmpty ? null : places.first.id, + selectedPlaceId: null, currentUser: currentUser, hasTelegramAuth: _authRepository.hasTelegramAuth, userCoordinate: null, @@ -201,10 +201,9 @@ class PlaceCubit extends Cubit { void clearTrait() { final value = _requireReady(); - final selectedPlaceId = value.places.isEmpty ? null : value.places.first.id; emit( PlaceViewState.ready( - value.copyWith(selectedTrait: null, selectedPlaceId: selectedPlaceId), + value.copyWith(selectedTrait: null, selectedPlaceId: null), ), ); } @@ -337,12 +336,11 @@ class PlaceCubit extends Cubit { ); final places = await _placesRepository.fetchPlaces(); - final selectedPlace = places.isEmpty ? null : places.first.id; emit( PlaceViewState.ready( value.copyWith( places: places, - selectedPlaceId: selectedPlace, + selectedPlaceId: null, reviewDraft: _emptyDraft, voiceFilterTranscript: '', voiceFilterTags: const [], diff --git a/lib/features/mapflow/presentation/mapflow_shell.dart b/lib/features/mapflow/presentation/mapflow_shell.dart index 3baea5f..04098e7 100644 --- a/lib/features/mapflow/presentation/mapflow_shell.dart +++ b/lib/features/mapflow/presentation/mapflow_shell.dart @@ -4,10 +4,10 @@ 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_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart' hide Path; +import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart' as mbx; import 'package:waveform_flutter/waveform_flutter.dart' show Amplitude; import 'package:waveform_recorder/waveform_recorder.dart'; @@ -21,10 +21,9 @@ import '../data/places_repository.dart'; import '../domain/place_models.dart'; import 'widgets/place_photo_card.dart'; -const _mapboxAccessToken = String.fromEnvironment('MAPBOX_ACCESS_TOKEN'); const _mapboxStyle = String.fromEnvironment( 'MAPBOX_STYLE', - defaultValue: 'mapbox/streets-v12', + defaultValue: 'mapbox/standard', ); class MapflowShell extends StatelessWidget { @@ -51,18 +50,24 @@ class MapflowShell extends StatelessWidget { } } -class _MapContent extends StatelessWidget { +class _MapContent extends StatefulWidget { const _MapContent({required this.state}); - static const _fallbackCenter = LatLng(10.7718, 106.6982); - final PlaceState state; + @override + State<_MapContent> createState() => _MapContentState(); +} + +class _MapContentState extends State<_MapContent> { + static const _fallbackCenter = LatLng(16.0544, 108.2022); + @override Widget build(BuildContext context) { + final state = widget.state; final selected = state.selectedPlace; final userCoordinate = state.userCoordinate; - final mapCenter = userCoordinate ?? selected?.coordinate ?? _fallbackCenter; + final mapCenter = _focusCenter(state) ?? _fallbackCenter; final placeCubit = context.read(); final traitCounts = _countPlaceTraits(state.places); final availableTraits = [ @@ -81,38 +86,13 @@ class _MapContent extends StatelessWidget { return Scaffold( body: Stack( children: [ - FlutterMap( - options: MapOptions( - initialCenter: mapCenter, - initialZoom: 14.2, - minZoom: 3, - maxZoom: 18, - ), - children: [ - const _BaseMapTileLayer(), - MarkerLayer( - markers: [ - for (final place in state.recommendations) - Marker( - width: 52, - height: 52, - point: place.coordinate, - child: _PlaceMarker( - selected: selected?.id == place.id, - onTap: () => _selectAndShowPlace(context, place), - ), - ), - if (userCoordinate != null) - Marker( - width: 30, - height: 30, - point: userCoordinate, - child: const _UserLocationMarker(), - ), - ], - ), - const _MapAttribution(), - ], + _MapboxMapLayer( + initialCenter: mapCenter, + focusCenter: _focusCenter(state), + places: state.recommendations, + selectedPlaceId: selected?.id, + userCoordinate: userCoordinate, + onPlaceTap: (place) => _selectAndShowPlace(context, place), ), SafeArea( child: Align( @@ -183,6 +163,10 @@ class _MapContent extends StatelessWidget { ); } + LatLng? _focusCenter(PlaceState state) { + return state.userCoordinate ?? state.selectedPlace?.coordinate; + } + Future _openSearchSheet( BuildContext context, { required List traits, @@ -314,6 +298,169 @@ class _UserLocationMarker extends StatelessWidget { } } +class _ProjectedMarker { + const _ProjectedMarker({required this.place, required this.offset}); + + final PlaceRecommendation place; + final Offset offset; +} + +class _MapboxMapLayer extends StatefulWidget { + const _MapboxMapLayer({ + required this.initialCenter, + required this.focusCenter, + required this.places, + required this.selectedPlaceId, + required this.userCoordinate, + required this.onPlaceTap, + }); + + final LatLng initialCenter; + final LatLng? focusCenter; + final List places; + final String? selectedPlaceId; + final LatLng? userCoordinate; + final ValueChanged onPlaceTap; + + @override + State<_MapboxMapLayer> createState() => _MapboxMapLayerState(); +} + +class _MapboxMapLayerState extends State<_MapboxMapLayer> { + mbx.MapboxMap? _mapboxMap; + var _mapLoaded = false; + var _didInitialFlyIn = false; + var _projectionVersion = 0; + LatLng? _lastFocusCenter; + List<_ProjectedMarker> _projectedPlaces = const []; + Offset? _projectedUserCoordinate; + + @override + void didUpdateWidget(covariant _MapboxMapLayer oldWidget) { + super.didUpdateWidget(oldWidget); + _updateCameraForFocus(); + unawaited(_projectMarkers()); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + mbx.MapWidget( + styleUri: _mapboxStyleUri, + viewport: mbx.CameraViewportState( + center: _mapboxPoint(widget.initialCenter), + zoom: _didInitialFlyIn ? 14.8 : 1.25, + bearing: _didInitialFlyIn ? -16 : 0, + pitch: _didInitialFlyIn ? 62 : 0, + ), + onMapCreated: _onMapCreated, + onMapLoadedListener: (_) { + _mapLoaded = true; + _updateCameraForFocus(); + unawaited(_projectMarkers()); + }, + onCameraChangeListener: (_) => unawaited(_projectMarkers()), + onMapIdleListener: (_) => unawaited(_projectMarkers()), + ), + for (final projected in _projectedPlaces) + Positioned( + left: projected.offset.dx - 26, + top: projected.offset.dy - 26, + width: 52, + height: 52, + child: _PlaceMarker( + selected: projected.place.id == widget.selectedPlaceId, + onTap: () => widget.onPlaceTap(projected.place), + ), + ), + if (_projectedUserCoordinate != null) + Positioned( + left: _projectedUserCoordinate!.dx - 15, + top: _projectedUserCoordinate!.dy - 15, + width: 30, + height: 30, + child: const _UserLocationMarker(), + ), + ], + ); + } + + void _onMapCreated(mbx.MapboxMap mapboxMap) { + _mapboxMap = mapboxMap; + } + + void _updateCameraForFocus() { + final mapboxMap = _mapboxMap; + final focusCenter = widget.focusCenter; + if (!_mapLoaded || mapboxMap == null || focusCenter == null) { + return; + } + if (focusCenter == _lastFocusCenter) { + return; + } + + _lastFocusCenter = focusCenter; + final camera = mbx.CameraOptions( + center: _mapboxPoint(focusCenter), + zoom: 14.8, + bearing: -16, + pitch: 62, + ); + if (_didInitialFlyIn) { + unawaited( + mapboxMap.easeTo(camera, mbx.MapAnimationOptions(duration: 900)), + ); + return; + } + + _didInitialFlyIn = true; + unawaited(mapboxMap.flyTo(camera, mbx.MapAnimationOptions(duration: 2600))); + } + + Future _projectMarkers() async { + final mapboxMap = _mapboxMap; + if (!_mapLoaded || mapboxMap == null) { + return; + } + + final version = ++_projectionVersion; + final coordinates = [ + for (final place in widget.places) _mapboxPoint(place.coordinate), + if (widget.userCoordinate != null) _mapboxPoint(widget.userCoordinate!), + ]; + if (coordinates.isEmpty) { + if (!mounted) { + return; + } + setState(() { + _projectedPlaces = const []; + _projectedUserCoordinate = null; + }); + return; + } + + final pixels = await mapboxMap.pixelsForCoordinates(coordinates); + if (!mounted || version != _projectionVersion) { + return; + } + + setState(() { + _projectedPlaces = [ + for (var index = 0; index < widget.places.length; index++) + _ProjectedMarker( + place: widget.places[index], + offset: Offset(pixels[index].x, pixels[index].y), + ), + ]; + _projectedUserCoordinate = widget.userCoordinate == null + ? null + : Offset(pixels.last.x, pixels.last.y); + }); + } +} + class _UserAvatar extends StatelessWidget { const _UserAvatar({ required this.user, @@ -916,12 +1063,13 @@ class _MapError extends StatelessWidget { return Scaffold( body: Stack( children: [ - FlutterMap( - options: const MapOptions( - initialCenter: LatLng(10.7718, 106.6982), - initialZoom: 14.2, - ), - children: [const _BaseMapTileLayer(), const _MapAttribution()], + const _MapboxMapLayer( + initialCenter: LatLng(16.0544, 108.2022), + focusCenter: LatLng(16.0544, 108.2022), + places: [], + selectedPlaceId: null, + userCoordinate: null, + onPlaceTap: _ignorePlaceTap, ), SafeArea( child: Align( @@ -944,50 +1092,22 @@ class _MapError extends StatelessWidget { } } -class _BaseMapTileLayer extends StatelessWidget { - const _BaseMapTileLayer(); +void _ignorePlaceTap(PlaceRecommendation _) {} - static const _osmUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; - - @override - Widget build(BuildContext context) { - if (_mapboxAccessToken.isEmpty) { - return TileLayer( - urlTemplate: _osmUrl, - userAgentPackageName: 'com.mapflow.app', - ); - } - - return TileLayer( - urlTemplate: - 'https://api.mapbox.com/styles/v1/$_mapboxStyle/tiles/512/{z}/{x}/{y}@2x' - '?access_token=$_mapboxAccessToken', - tileDimension: 512, - zoomOffset: -1, - maxNativeZoom: 22, - userAgentPackageName: 'com.mapflow.app', - ); - } +mbx.Point _mapboxPoint(LatLng coordinate) { + return mbx.Point( + coordinates: mbx.Position(coordinate.longitude, coordinate.latitude), + ); } -class _MapAttribution extends StatelessWidget { - const _MapAttribution(); - - @override - Widget build(BuildContext context) { - if (_mapboxAccessToken.isEmpty) { - return const RichAttributionWidget( - attributions: [TextSourceAttribution('OpenStreetMap contributors')], - ); - } - - return const RichAttributionWidget( - attributions: [ - TextSourceAttribution('Mapbox', prependCopyright: false), - TextSourceAttribution('OpenStreetMap contributors'), - ], - ); +String get _mapboxStyleUri { + if (_mapboxStyle.startsWith('mapbox://')) { + return _mapboxStyle; } + if (_mapboxStyle.startsWith('mapbox/')) { + return 'mapbox://styles/$_mapboxStyle'; + } + return _mapboxStyle; } class _TraitPickerSheet extends StatelessWidget { diff --git a/lib/main.dart b/lib/main.dart index 8e91b3d..6cb85d7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart' as mbx; import 'app/app.dart'; import 'shared/auth/telegram_session.dart' as telegram_session; +const _mapboxAccessToken = String.fromEnvironment('MAPBOX_ACCESS_TOKEN'); + void main() { WidgetsFlutterBinding.ensureInitialized(); + if (_mapboxAccessToken.isNotEmpty) { + mbx.MapboxOptions.setAccessToken(_mapboxAccessToken); + } usePathUrlStrategy(); telegram_session.configureTelegramWebApp(); runApp(const MapflowApp()); diff --git a/pubspec.lock b/pubspec.lock index 40d966b..f35c878 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + benchmark: + dependency: transitive + description: + name: benchmark + sha256: cb3eeea01e3f054df76ee9775ca680f3afa5f19f39b2bb426ba78ba27654493b + url: "https://pub.dev" + source: hosted + version: "0.3.0" bloc: dependency: transitive description: @@ -209,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + dart_sort_queue: + dependency: transitive + description: + name: dart_sort_queue + sha256: f3353ba8b4850e072d3368757f62edb79af34a9703c3e3df9c59342721f5f5b1 + url: "https://pub.dev" + source: hosted + version: "0.0.2+3" dart_style: dependency: transitive description: @@ -342,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.3.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_svg: dependency: "direct main" description: @@ -424,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + geotypes: + dependency: transitive + description: + name: geotypes + sha256: "5bedf57de92283133dd221e363812ef50eaaba414f0823b1974ef7d84b86991f" + url: "https://pub.dev" + source: hosted + version: "0.0.2" glob: dependency: transitive description: @@ -640,6 +672,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + mapbox_maps_flutter: + dependency: "direct main" + description: + name: mapbox_maps_flutter + sha256: f98e3808113088c6e0bc60f592d23eb5a23215b10a9389754642c8e5f9bdc117 + url: "https://pub.dev" + source: hosted + version: "3.0.0-alpha.1" + mapbox_maps_flutter_mobile: + dependency: transitive + description: + name: mapbox_maps_flutter_mobile + sha256: "4ef1a6cb23dfe4a6f5bb896ea7b1195dd474aaffd056bab77434fda03bc9446b" + url: "https://pub.dev" + source: hosted + version: "3.0.0-alpha.1" + mapbox_maps_flutter_platform_interface: + dependency: transitive + description: + name: mapbox_maps_flutter_platform_interface + sha256: e64dc1df85cb2fe5c7d704771a01e53b6d6ccdafb8be70c2879c0d7e8bf6cbd8 + url: "https://pub.dev" + source: hosted + version: "3.0.0-alpha.1" + mapbox_maps_flutter_web: + dependency: transitive + description: + name: mapbox_maps_flutter_web + sha256: "01ef5f2b4578e33cbdae27d05b7b9aa37246b205746a552f507d66b0deaf20cc" + url: "https://pub.dev" + source: hosted + version: "3.0.0-alpha.1" matcher: dependency: transitive description: @@ -880,6 +944,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + rbush: + dependency: transitive + description: + name: rbush + sha256: "48b683421b4afb43a642f82c6aa31911e54f3069143d31c7d33cbe329df13403" + url: "https://pub.dev" + source: hosted + version: "1.1.1" record: dependency: transitive description: @@ -1045,6 +1117,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.5" + sweepline_intersections: + dependency: transitive + description: + name: sweepline_intersections + sha256: a665c707200a4f07140a4029b41a7c4883beb3f04322cd8e08ebf650f69e1176 + url: "https://pub.dev" + source: hosted + version: "0.0.4" term_glyph: dependency: transitive description: @@ -1061,6 +1141,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + turf: + dependency: transitive + description: + name: turf + sha256: "7684654c561e751332bc2fa6e9bb7bcf0c71bad5315acf126206e7ecfef67256" + url: "https://pub.dev" + source: hosted + version: "0.0.12" + turf_equality: + dependency: transitive + description: + name: turf_equality + sha256: f0f44ffe389547941358e0d3d4a747db2bd56115b32ff1cede5e5bdf3126a3e2 + url: "https://pub.dev" + source: hosted + version: "0.1.0" + turf_pip: + dependency: transitive + description: + name: turf_pip + sha256: ba4fd414baffd5d7b30880658ad6db82461c49ec023f8ffd0c23d398ad8b14be + url: "https://pub.dev" + source: hosted + version: "0.0.2" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 462cd90..cb09e32 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: gql_code_builder_serializers: ^0.1.0+1 built_collection: ^5.1.1 built_value: ^8.12.6 + mapbox_maps_flutter: ^3.0.0-alpha.1 dev_dependencies: flutter_test: