diff --git a/.gitignore b/.gitignore
index 0cbf081..c858208 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,6 @@ app.*.map.json
.env
.dev.vars
node_modules
-.wrangler
\ No newline at end of file
+.wrangler
+
+.vscode/
\ No newline at end of file
diff --git a/assets/pin.png b/assets/pin.png
new file mode 100644
index 0000000..2792ace
Binary files /dev/null and b/assets/pin.png differ
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
index 1dc6cf7..163000d 100644
--- a/ios/Flutter/AppFrameworkInfo.plist
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -21,6 +21,6 @@
CFBundleVersion
1.0
MinimumOSVersion
- 13.0
+ 14.0
diff --git a/ios/Podfile b/ios/Podfile
index 3c51025..fa57b45 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -1,7 +1,7 @@
source 'https://github.com/CocoaPods/Specs.git'
# Uncomment this line to define a global platform for your project
-platform :ios, '13.0'
+platform :ios, '14.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index a0bde07..5515e42 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -40,10 +40,23 @@ PODS:
- GTMSessionFetcher/Core (3.5.0)
- GTMSessionFetcher/Full (3.5.0):
- GTMSessionFetcher/Core
+ - mapbox_maps_flutter (2.19.1):
+ - Flutter
+ - MapboxMaps (= 11.19.0)
+ - Turf (= 4.0.0)
+ - MapboxCommon (24.19.0):
+ - Turf (= 4.0.0)
+ - MapboxCoreMaps (11.19.0):
+ - MapboxCommon (= 24.19.0)
+ - MapboxMaps (11.19.0):
+ - MapboxCommon (= 24.19.0)
+ - MapboxCoreMaps (= 11.19.0)
+ - Turf (= 4.0.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0)
+ - Turf (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
@@ -51,6 +64,7 @@ DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`)
+ - mapbox_maps_flutter (from `.symlinks/plugins/mapbox_maps_flutter/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@@ -62,7 +76,11 @@ SPEC REPOS:
- GoogleUtilities
- GTMAppAuth
- GTMSessionFetcher
+ - MapboxCommon
+ - MapboxCoreMaps
+ - MapboxMaps
- PromisesObjC
+ - Turf
EXTERNAL SOURCES:
Flutter:
@@ -71,6 +89,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
google_sign_in_ios:
:path: ".symlinks/plugins/google_sign_in_ios/darwin"
+ mapbox_maps_flutter:
+ :path: ".symlinks/plugins/mapbox_maps_flutter/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
url_launcher_ios:
@@ -86,10 +106,15 @@ SPEC CHECKSUMS:
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
+ mapbox_maps_flutter: 632563764d2dff820a123312fa5b50417a214b1f
+ MapboxCommon: b40bcf330493e0d218802a63cabbd2efe6ca303b
+ MapboxCoreMaps: badf69766576702ccb09a901c5859a45f0f600c0
+ MapboxMaps: db05259e1ea68e9a8a4e6f9014027418025f2ebe
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
+ Turf: c9eb11a65d96af58cac523460fd40fec5061b081
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
-PODFILE CHECKSUM: 9ca1682d392c3b88ad6d313259873375a305cdca
+PODFILE CHECKSUM: 9d0963e5127f1378f44f0c102f08e544ad83bf96
COCOAPODS: 1.16.2
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 4dadb88..d715090 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -473,7 +473,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -602,7 +602,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -653,7 +653,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
diff --git a/lib/features/map/presentation/map_screen.dart b/lib/features/map/presentation/map_screen.dart
index f606fbd..1d659dd 100644
--- a/lib/features/map/presentation/map_screen.dart
+++ b/lib/features/map/presentation/map_screen.dart
@@ -1,14 +1,16 @@
import 'package:flutter/material.dart';
-import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:latlong2/latlong.dart';
-import 'package:url_launcher/url_launcher.dart';
import 'package:memomap/features/auth/providers/auth_provider.dart';
import 'package:memomap/features/map/providers/pin_provider.dart';
import 'package:memomap/features/map/providers/drawing_provider.dart';
-import 'package:memomap/features/map/presentation/widgets/drawing_canvas.dart';
+import 'package:memomap/features/map/models/drawing_path.dart';
import 'package:memomap/features/map/presentation/widgets/controls.dart';
+import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
+import 'package:flutter/services.dart';
+import 'dart:math' as math;
+import 'dart:convert';
class MapScreen extends ConsumerStatefulWidget {
const MapScreen({super.key});
@@ -18,39 +20,409 @@ class MapScreen extends ConsumerStatefulWidget {
}
class _MapScreenState extends ConsumerState {
- late final MapController _mapController;
+ MapboxMap? _mapboxMap;
+ PointAnnotationManager? pointAnnotationManager;
+ bool _isDimmed = false; // ピン選択時に背景を暗くする
+ PinData? _activePin; // 選択されたピン
+ Offset? _activePinScreenPos; // 選択されたピンのスクリーン座標
+ Uint8List? _pinImageData;
+ List _currentLatLngs = [];
+ Offset? _eraserPosition;
+ final Map _annotationToPin = {};
@override
void initState() {
super.initState();
- _mapController = MapController();
+ _loadPinImage();
}
- @override
- void dispose() {
- _mapController.dispose();
- super.dispose();
+ Future _loadPinImage() async {
+ final ByteData bytes = await rootBundle.load('assets/pin.png');
+ _pinImageData = bytes.buffer.asUint8List();
+ }
+
+ void _onMapCreated(MapboxMap mapboxMap) async {
+ _mapboxMap = mapboxMap;
+ pointAnnotationManager = await mapboxMap.annotations
+ .createPointAnnotationManager();
+
+ pointAnnotationManager?.longPressEvents(
+ onLongPress: (annotation) {
+ _handlePinLongPress(annotation);
+ },
+ );
+
+ _updatePins();
+
+ setState(() {}); // _mapboxMapが設定されたことを通知
+ }
+
+ Future _updatePins() async {
+ if (pointAnnotationManager == null || _pinImageData == null) return;
+ await pointAnnotationManager!.deleteAll();
+ _annotationToPin.clear();
+
+ final pins = ref.read(pinsProvider).value ?? [];
+ for (PinData pin in pins) {
+ final annotation = await pointAnnotationManager!.create(
+ PointAnnotationOptions(
+ geometry: Point(
+ coordinates: Position(
+ pin.position.longitude,
+ pin.position.latitude,
+ ),
+ ),
+ image: _pinImageData,
+ iconSize: 0.5,
+ iconAnchor: IconAnchor.BOTTOM,
+ ),
+ );
+ _annotationToPin[annotation.id] = pin;
+ }
+ }
+
+ void _handlePinLongPress(PointAnnotation annotation) async {
+ final pin = _annotationToPin[annotation.id];
+ if (pin == null || _mapboxMap == null) return;
+
+ final screenPos = await _mapboxMap!.pixelForCoordinate(
+ Point(
+ coordinates: Position(pin.position.longitude, pin.position.latitude),
+ ),
+ );
+
+ setState(() {
+ _isDimmed = true;
+ _activePin = pin;
+ _activePinScreenPos = Offset(
+ screenPos.x.toDouble(),
+ screenPos.y.toDouble(),
+ );
+ });
+
+ if (!mounted) return;
+
+ final selected = await showMenu(
+ context: context,
+ position: RelativeRect.fromLTRB(
+ _activePinScreenPos!.dx,
+ _activePinScreenPos!.dy,
+ _activePinScreenPos!.dx,
+ _activePinScreenPos!.dy,
+ ),
+ items: const [
+ PopupMenuItem(
+ value: "delete",
+ child: Text("Delete", style: TextStyle(color: Colors.red)),
+ ),
+ ],
+ );
+
+ if (mounted) {
+ setState(() {
+ _isDimmed = false;
+ _activePin = null;
+ _activePinScreenPos = null;
+ });
+ }
+
+ if (selected == "delete") {
+ ref.read(pinsProvider.notifier).deletePin(pin.id);
+ }
+ }
+
+ void _onStyleLoaded(StyleLoadedEventData data) async {
+ final style = _mapboxMap?.style;
+ if (style == null) return;
+
+ // 既存のパス用ソースとレイヤー
+ await style.addSource(GeoJsonSource(id: "existing_paths_source"));
+ await style.addLayer(
+ LineLayer(
+ id: "existing_paths_layer",
+ sourceId: "existing_paths_source",
+ lineJoin: LineJoin.ROUND,
+ lineCap: LineCap.ROUND,
+ ),
+ );
+ // データ駆動型スタイリングの設定
+ await style.setStyleLayerProperty("existing_paths_layer", "line-color", [
+ "get",
+ "color",
+ ]);
+ await style.setStyleLayerProperty("existing_paths_layer", "line-width", [
+ "get",
+ "width",
+ ]);
+
+ // 現在描画中のパス用ソースとレイヤー
+ await style.addSource(GeoJsonSource(id: "current_path_source"));
+ await style.addLayer(
+ LineLayer(
+ id: "current_path_layer",
+ sourceId: "current_path_source",
+ lineJoin: LineJoin.ROUND,
+ lineCap: LineCap.ROUND,
+ ),
+ );
+
+ _updateLines();
}
- List _buildMarkers(List pins) {
- return pins.map((pin) {
- return Marker(
- point: pin.position,
- width: 60,
- height: 60,
- alignment: Alignment.topCenter,
- child: const Icon(Icons.location_pin, size: 60, color: Colors.red),
+ String _colorToRgb(Color color) {
+ return 'rgb(${(color.r * 255.0).round().clamp(0, 255)}, ${(color.g * 255.0).round().clamp(0, 255)}, ${(color.b * 255.0).round().clamp(0, 255)})';
+ }
+
+ Future _updateLines() async {
+ final style = _mapboxMap?.style;
+ if (style == null) return;
+
+ final drawingState = ref.read(drawingProvider);
+ final features = drawingState.paths.asMap().entries.map((entry) {
+ final index = entry.key;
+ final path = entry.value;
+ return Feature(
+ id: "path_$index",
+ geometry: LineString(
+ coordinates: path.points
+ .map((p) => Position(p.longitude, p.latitude))
+ .toList(),
+ ),
+ properties: {
+ "color": _colorToRgb(path.color),
+ "width": path.strokeWidth,
+ },
);
}).toList();
+
+ try {
+ final collection = FeatureCollection(features: features);
+ await style.setStyleSourceProperty(
+ "existing_paths_source",
+ "data",
+ jsonEncode(collection.toJson()),
+ );
+ } catch (e) {
+ debugPrint("Error updating existing_paths_source: $e");
+ }
+ }
+
+ Future _updateCurrentPath() async {
+ final style = _mapboxMap?.style;
+ if (style == null) return;
+
+ final drawingState = ref.read(drawingProvider);
+ final features = _currentLatLngs.isEmpty
+ ? []
+ : [
+ Feature(
+ id: "current_path",
+ geometry: LineString(
+ coordinates: _currentLatLngs
+ .map((p) => Position(p.longitude, p.latitude))
+ .toList(),
+ ),
+ ),
+ ];
+
+ try {
+ final collection = FeatureCollection(features: features);
+ await style.setStyleSourceProperty(
+ "current_path_source",
+ "data",
+ jsonEncode(collection.toJson()),
+ );
+
+ if (_currentLatLngs.isNotEmpty) {
+ await style.setStyleLayerProperty(
+ "current_path_layer",
+ "line-color",
+ _colorToRgb(drawingState.selectedColor),
+ );
+ await style.setStyleLayerProperty(
+ "current_path_layer",
+ "line-width",
+ drawingState.strokeWidth,
+ );
+ }
+ } catch (e) {
+ debugPrint("Error updating current_path_source: $e");
+ }
+ }
+
+ void _onMapTap(MapContentGestureContext context) {
+ if (ref.read(drawingProvider).isDrawingMode) return;
+ final latLng = LatLng(
+ context.point.coordinates.lat.toDouble(),
+ context.point.coordinates.lng.toDouble(),
+ );
+ ref.read(pinsProvider.notifier).addPin(latLng);
+ }
+
+ Future _convertToLatLng(Offset offset) async {
+ if (_mapboxMap == null) return;
+ final point = await _mapboxMap!.coordinateForPixel(
+ ScreenCoordinate(x: offset.dx, y: offset.dy),
+ );
+ if (mounted) {
+ setState(() {
+ _currentLatLngs.add(
+ LatLng(
+ point.coordinates.lat.toDouble(),
+ point.coordinates.lng.toDouble(),
+ ),
+ );
+ });
+ }
+ }
+
+ Future _handleEraser(Offset localPosition) async {
+ if (_mapboxMap == null) return;
+ final drawingState = ref.read(drawingProvider);
+ final drawingNotifier = ref.read(drawingProvider.notifier);
+
+ final point = await _mapboxMap!.coordinateForPixel(
+ ScreenCoordinate(x: localPosition.dx, y: localPosition.dy),
+ );
+ final latLng = LatLng(
+ point.coordinates.lat.toDouble(),
+ point.coordinates.lng.toDouble(),
+ );
+
+ final cameraState = await _mapboxMap!.getCameraState();
+ final zoom = cameraState.zoom;
+
+ final distance = const Distance();
+
+ // 消しゴムの半径(メートル換算)。
+ // Mapboxは512pxタイルを使用するため、ズーム0での1ピクセルあたりのメートル数は約78271.5
+ final metersPerPixel =
+ 78271.51696 *
+ math.cos(latLng.latitude * math.pi / 180) /
+ math.pow(2, zoom);
+ final eraserRadius = (drawingState.strokeWidth * 2) * metersPerPixel;
+
+ List newPaths = [];
+ bool changed = false;
+
+ for (final path in drawingState.paths) {
+ List currentSegment = [];
+ bool pathModified = false;
+
+ for (final p in path.points) {
+ if (distance(latLng, p) < eraserRadius) {
+ if (currentSegment.length > 1) {
+ newPaths.add(
+ DrawingPath(
+ points: List.from(currentSegment),
+ color: path.color,
+ strokeWidth: path.strokeWidth,
+ ),
+ );
+ }
+ currentSegment = [];
+ pathModified = true;
+ changed = true;
+ } else {
+ currentSegment.add(p);
+ }
+ }
+
+ if (currentSegment.length > 1) {
+ newPaths.add(
+ DrawingPath(
+ points: currentSegment,
+ color: path.color,
+ strokeWidth: path.strokeWidth,
+ ),
+ );
+ } else if (pathModified && currentSegment.length <= 1) {
+ } else if (!pathModified) {
+ newPaths.add(path);
+ }
+ }
+
+ if (changed) {
+ drawingNotifier.setPaths(newPaths);
+ }
+ }
+
+ void _onPanStart(DragStartDetails details) async {
+ final drawingState = ref.read(drawingProvider);
+ if (drawingState.isEraserMode) {
+ setState(() {
+ _eraserPosition = details.localPosition;
+ });
+ await _handleEraser(details.localPosition);
+ return;
+ }
+
+ _currentLatLngs = [];
+ await _convertToLatLng(details.localPosition);
+ await _updateCurrentPath();
+ }
+
+ void _onPanUpdate(DragUpdateDetails details) async {
+ final drawingState = ref.read(drawingProvider);
+ if (drawingState.isEraserMode) {
+ setState(() {
+ _eraserPosition = details.localPosition;
+ });
+ await _handleEraser(details.localPosition);
+ return;
+ }
+
+ await _convertToLatLng(details.localPosition);
+ await _updateCurrentPath();
+ }
+
+ void _onPanEnd(DragEndDetails details) async {
+ final drawingState = ref.read(drawingProvider);
+ if (drawingState.isEraserMode) {
+ setState(() {
+ _eraserPosition = null;
+ });
+ return;
+ }
+
+ if (_currentLatLngs.length > 1) {
+ ref
+ .read(drawingProvider.notifier)
+ .addPath(
+ DrawingPath(
+ points: List.from(_currentLatLngs),
+ color: drawingState.selectedColor,
+ strokeWidth: drawingState.strokeWidth,
+ ),
+ );
+ }
+
+ _currentLatLngs = [];
+ await _updateCurrentPath();
+ setState(() {});
+ }
+
+ @override
+ void dispose() {
+ super.dispose();
}
@override
Widget build(BuildContext context) {
final isAuthenticated = ref.watch(isAuthenticatedProvider);
final user = ref.watch(currentUserProvider);
- final pinsAsync = ref.watch(pinsProvider);
final drawingState = ref.watch(drawingProvider);
+ ref.listen(drawingProvider.select((s) => s.paths), (previous, next) {
+ _updateLines();
+ });
+
+ ref.listen(pinsProvider, (previous, next) {
+ _updatePins();
+ });
+
+ MapboxMapsOptions.setLanguage("ja");
+
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
@@ -74,103 +446,148 @@ class _MapScreenState extends ConsumerState {
),
],
),
- body: Column(
+ body: Stack(
children: [
- Expanded(
- child: Stack(
- children: [
- FlutterMap(
- mapController: _mapController,
- options: MapOptions(
- initialCenter: const LatLng(35.6895, 139.6917),
- initialZoom: 9.2,
- interactionOptions: InteractionOptions(
- flags: drawingState.isDrawingMode
- ? InteractiveFlag.none
- : InteractiveFlag.all &
- ~InteractiveFlag.doubleTapZoom,
- ),
- onTap: (tapPosition, latlng) {
- if (!drawingState.isDrawingMode) {
- ref.read(pinsProvider.notifier).addPin(latlng);
- }
- },
- ),
+ Column(
+ children: [
+ Expanded(
+ child: Stack(
children: [
- TileLayer(
- urlTemplate:
- 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
- userAgentPackageName: 'dev.fleaflet.flutter_map.example',
- ),
- PolylineLayer(
- polylines: drawingState.paths
- .map(
- (path) => Polyline(
- points: path.points,
- color: path.color,
- strokeWidth: path.strokeWidth,
- ),
- )
- .toList(),
- ),
- MarkerLayer(
- markers: pinsAsync.when(
- data: _buildMarkers,
- loading: () => [],
- error: (_, _) => [],
+ MapWidget(
+ cameraOptions: CameraOptions(
+ center: Point(coordinates: Position(139.767, 35.681)),
+ zoom: 12,
+ bearing: 0,
+ pitch: 0,
),
+ onMapCreated: _onMapCreated,
+ onStyleLoadedListener: _onStyleLoaded,
+ onTapListener: _onMapTap,
+ gestureRecognizers: drawingState.isDrawingMode
+ ? {}
+ : null,
),
- RichAttributionWidget(
- alignment: AttributionAlignment.bottomLeft,
- attributions: [
- TextSourceAttribution(
- 'OpenStreetMap contributors',
- onTap: () => launchUrl(
- Uri.parse('https://openstreetmap.org/copyright'),
+ if (_mapboxMap != null)
+ IgnorePointer(
+ ignoring: !drawingState.isDrawingMode,
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onPanStart: _onPanStart,
+ onPanUpdate: _onPanUpdate,
+ onPanEnd: _onPanEnd,
+ child: Stack(
+ children: [
+ Container(color: Colors.transparent),
+ if (_eraserPosition != null)
+ Positioned(
+ left:
+ _eraserPosition!.dx -
+ drawingState.strokeWidth * 2,
+ top:
+ _eraserPosition!.dy -
+ drawingState.strokeWidth * 2,
+ child: Container(
+ width: drawingState.strokeWidth * 4,
+ height: drawingState.strokeWidth * 4,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(
+ color: Colors.black.withValues(
+ alpha: 0.5,
+ ),
+ width: 1.0,
+ ),
+ ),
+ ),
+ ),
+ ],
),
),
- ],
- ),
- ],
- ),
- IgnorePointer(
- ignoring: !drawingState.isDrawingMode,
- child: DrawingCanvas(mapController: _mapController),
- ),
- Positioned(
- right: 16,
- bottom: 16,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- FloatingActionButton(
- heroTag: 'zoom_in',
- onPressed: () => _mapController.move(
- _mapController.camera.center,
- _mapController.camera.zoom + 1,
- ),
- tooltip: 'Zoom in',
- child: const Icon(Icons.add),
),
- const SizedBox(height: 8),
- FloatingActionButton(
- heroTag: 'zoom_out',
- onPressed: () => _mapController.move(
- _mapController.camera.center,
- _mapController.camera.zoom - 1,
- ),
- tooltip: 'Zoom out',
- child: const Icon(Icons.remove),
+ Positioned(
+ right: 16,
+ bottom: 40,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ FloatingActionButton(
+ heroTag: 'zoom_in',
+ onPressed: () async {
+ final camera = await _mapboxMap?.getCameraState();
+ if (camera != null) {
+ _mapboxMap?.setCamera(
+ CameraOptions(zoom: camera.zoom + 1),
+ );
+ }
+ },
+ tooltip: 'Zoom in',
+ child: const Icon(Icons.add),
+ ),
+ const SizedBox(height: 8),
+ FloatingActionButton(
+ heroTag: 'zoom_out',
+ onPressed: () async {
+ final camera = await _mapboxMap?.getCameraState();
+ if (camera != null) {
+ _mapboxMap?.setCamera(
+ CameraOptions(zoom: camera.zoom - 1),
+ );
+ }
+ },
+ tooltip: 'Zoom out',
+ child: const Icon(Icons.remove),
+ ),
+ ],
),
- ],
- ),
+ ),
+ ],
),
- ],
+ ),
+ const Controls(),
+ ],
+ ),
+ // ピン選択時に背景を暗くするウィジェット
+ IgnorePointer(
+ ignoring: !_isDimmed,
+ child: AnimatedOpacity(
+ opacity: _isDimmed ? 1.0 : 0.0,
+ duration: const Duration(milliseconds: 200),
+ child: Container(color: Colors.black.withValues(alpha: 0.4)),
),
),
- const Controls(),
+ // ピン選択時に浮き上がるピン
+ if (_isDimmed && _activePin != null && _activePinScreenPos != null)
+ Positioned(
+ left: _activePinScreenPos!.dx - 18,
+ top: _activePinScreenPos!.dy - 36,
+ width: 36,
+ height: 36,
+ child: const _PinIcon(isFloating: true),
+ ),
],
),
);
}
}
+
+class _PinIcon extends StatelessWidget {
+ final bool isFloating;
+
+ const _PinIcon({required this.isFloating});
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedContainer(
+ duration: const Duration(milliseconds: 200),
+ curve: Curves.easeOut,
+ alignment: Alignment.bottomCenter,
+ transform: Matrix4.diagonal3Values(
+ isFloating ? 1.2 : 1.0,
+ isFloating ? 1.2 : 1.0,
+ 1.0,
+ ),
+ transformAlignment: Alignment.bottomCenter,
+ child: Image.asset('assets/pin.png', fit: BoxFit.contain),
+ );
+ }
+}
diff --git a/lib/features/map/presentation/widgets/drawing_canvas.dart b/lib/features/map/presentation/widgets/drawing_canvas.dart
index 6b10b20..8b13789 100644
--- a/lib/features/map/presentation/widgets/drawing_canvas.dart
+++ b/lib/features/map/presentation/widgets/drawing_canvas.dart
@@ -1,226 +1 @@
-import 'dart:math' as math;
-import 'package:flutter/material.dart';
-import 'package:flutter_map/flutter_map.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:latlong2/latlong.dart' hide Path;
-import 'package:memomap/features/map/models/drawing_path.dart';
-import 'package:memomap/features/map/providers/drawing_provider.dart';
-class DrawingCanvas extends ConsumerStatefulWidget {
- final MapController mapController;
- const DrawingCanvas({super.key, required this.mapController});
-
- @override
- ConsumerState createState() => _DrawingCanvasState();
-}
-
-class _DrawingCanvasState extends ConsumerState {
- DrawingPath? _currentPath;
- Offset? _eraserPosition;
-
- void _handleEraser(Offset localPosition) {
- final drawingState = ref.read(drawingProvider);
- final drawingNotifier = ref.read(drawingProvider.notifier);
-
- final latLng = widget.mapController.camera.screenOffsetToLatLng(
- localPosition,
- );
- final distance = const Distance();
-
- // 消しゴムの半径(メートル換算)。strokeWidthを基準にする
- final metersPerPixel =
- 156543.03392 *
- math.cos(latLng.latitude * math.pi / 180) /
- math.pow(2, widget.mapController.camera.zoom);
- final eraserRadius = drawingState.strokeWidth * metersPerPixel * 2;
-
- List newPaths = [];
- bool changed = false;
-
- for (final path in drawingState.paths) {
- List currentSegment = [];
- bool pathModified = false;
-
- for (final point in path.points) {
- if (distance(latLng, point) < eraserRadius) {
- if (currentSegment.length > 1) {
- newPaths.add(
- DrawingPath(
- points: List.from(currentSegment),
- color: path.color,
- strokeWidth: path.strokeWidth,
- ),
- );
- }
- currentSegment = [];
- pathModified = true;
- changed = true;
- } else {
- currentSegment.add(point);
- }
- }
-
- // 最後のセグメントを追加
- if (currentSegment.length > 1) {
- newPaths.add(
- DrawingPath(
- points: currentSegment,
- color: path.color,
- strokeWidth: path.strokeWidth,
- ),
- );
- } else if (pathModified && currentSegment.length <= 1) {
- // セグメントが短くなりすぎた場合は追加しない
- } else if (!pathModified) {
- // 修正がなかった場合は元のパスを維持
- newPaths.add(path);
- }
- }
-
- if (changed) {
- drawingNotifier.setPaths(newPaths);
- }
- }
-
- @override
- Widget build(BuildContext context) {
- final drawingState = ref.watch(drawingProvider);
- final drawingNotifier = ref.read(drawingProvider.notifier);
-
- return GestureDetector(
- behavior: HitTestBehavior.opaque,
- onPanStart: (details) {
- if (!drawingState.isDrawingMode) return;
-
- if (drawingState.isEraserMode) {
- setState(() {
- _eraserPosition = details.localPosition;
- });
- _handleEraser(details.localPosition);
- return;
- }
-
- final latLng = widget.mapController.camera.screenOffsetToLatLng(
- details.localPosition,
- );
- setState(() {
- _currentPath = DrawingPath(
- points: [latLng],
- color: drawingState.selectedColor,
- strokeWidth: drawingState.strokeWidth,
- );
- });
- },
- onPanUpdate: (details) {
- if (!drawingState.isDrawingMode) return;
-
- if (drawingState.isEraserMode) {
- setState(() {
- _eraserPosition = details.localPosition;
- });
- _handleEraser(details.localPosition);
- return;
- }
-
- if (_currentPath == null) return;
- final latLng = widget.mapController.camera.screenOffsetToLatLng(
- details.localPosition,
- );
- setState(() {
- _currentPath = _currentPath!.copyWith(
- points: [..._currentPath!.points, latLng],
- );
- });
- },
- onPanEnd: (details) {
- if (drawingState.isEraserMode) {
- setState(() {
- _eraserPosition = null;
- });
- return;
- }
-
- if (_currentPath != null && _currentPath!.points.length > 1) {
- drawingNotifier.addPath(_currentPath!);
- }
- setState(() {
- _currentPath = null;
- });
- },
- child: Stack(
- children: [
- if (_currentPath != null)
- CustomPaint(
- size: Size.infinite,
- painter: _CurrentPathPainter(_currentPath!, widget.mapController),
- ),
- if (_eraserPosition != null)
- CustomPaint(
- size: Size.infinite,
- painter: _EraserPainter(
- _eraserPosition!,
- drawingState.strokeWidth * 2,
- ),
- ),
- ],
- ),
- );
- }
-}
-
-class _CurrentPathPainter extends CustomPainter {
- final DrawingPath drawingPath;
- final MapController mapController;
-
- _CurrentPathPainter(this.drawingPath, this.mapController);
-
- @override
- void paint(Canvas canvas, Size size) {
- if (drawingPath.points.length < 2) return;
-
- final paint = Paint()
- ..strokeCap = StrokeCap.round
- ..strokeJoin = StrokeJoin.round
- ..isAntiAlias = true
- ..color = drawingPath.color
- ..strokeWidth = drawingPath.strokeWidth
- ..style = PaintingStyle.stroke;
-
- final path = Path();
- for (var i = 0; i < drawingPath.points.length; i++) {
- final offset = mapController.camera.latLngToScreenOffset(
- drawingPath.points[i],
- );
- if (i == 0) {
- path.moveTo(offset.dx, offset.dy);
- } else {
- path.lineTo(offset.dx, offset.dy);
- }
- }
-
- canvas.drawPath(path, paint);
- }
-
- @override
- bool shouldRepaint(_CurrentPathPainter oldDelegate) => true;
-}
-
-class _EraserPainter extends CustomPainter {
- final Offset position;
- final double radius;
-
- _EraserPainter(this.position, this.radius);
-
- @override
- void paint(Canvas canvas, Size size) {
- final paint = Paint()
- ..color = Colors.black.withValues(alpha: 0.5)
- ..style = PaintingStyle.stroke
- ..strokeWidth = 1.0;
- canvas.drawCircle(position, radius, paint);
- }
-
- @override
- bool shouldRepaint(_EraserPainter oldDelegate) =>
- oldDelegate.position != position || oldDelegate.radius != radius;
-}
diff --git a/lib/main.dart b/lib/main.dart
index 8c66d1c..998ffad 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -2,9 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:memomap/app.dart';
+import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
+ // ignore: non_constant_identifier_names
+ String ACCESS_TOKEN = const String.fromEnvironment("ACCESS_TOKEN");
+ MapboxOptions.setAccessToken(ACCESS_TOKEN);
await dotenv.load(fileName: '.env');
runApp(const ProviderScope(child: App()));
diff --git a/pubspec.lock b/pubspec.lock
index efbe210..bd6b2d9 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
+ benchmark:
+ dependency: transitive
+ description:
+ name: benchmark
+ sha256: cb3eeea01e3f054df76ee9775ca680f3afa5f19f39b2bb426ba78ba27654493b
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.0"
boolean_selector:
dependency: transitive
description:
@@ -185,6 +193,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:
@@ -286,6 +302,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.2.2"
+ flutter_plugin_android_lifecycle:
+ dependency: transitive
+ description:
+ name: flutter_plugin_android_lifecycle
+ sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.33"
flutter_riverpod:
dependency: "direct main"
description:
@@ -360,6 +384,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
+ geotypes:
+ dependency: transitive
+ description:
+ name: geotypes
+ sha256: "5bedf57de92283133dd221e363812ef50eaaba414f0823b1974ef7d84b86991f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.0.2"
glob:
dependency: transitive
description:
@@ -592,6 +624,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
+ mapbox_maps_flutter:
+ dependency: "direct main"
+ description:
+ name: mapbox_maps_flutter
+ sha256: "982c7ef853190e16cdf06332ec1b5274b9c4e77cb35d81330aa84c4242faf4ad"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.19.1"
matcher:
dependency: transitive
description:
@@ -760,6 +800,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
+ rbush:
+ dependency: transitive
+ description:
+ name: rbush
+ sha256: "48b683421b4afb43a642f82c6aa31911e54f3069143d31c7d33cbe329df13403"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
retrofit:
dependency: "direct main"
description:
@@ -885,6 +933,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.42.0"
+ 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:
@@ -901,6 +957,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
+ turf:
+ dependency: transitive
+ description:
+ name: turf
+ sha256: "75347c45a5c1de805db7cb182286f05a3770e01546626c4dc292709d15cbe436"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.0.10"
+ 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 be4e188..77690f1 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -30,6 +30,7 @@ environment:
dependencies:
flutter:
sdk: flutter
+ mapbox_maps_flutter: ^2.19.1
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
@@ -80,6 +81,7 @@ flutter:
assets:
- .env
+ - assets/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images