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