From 1877139e41270ba66af96d8b9046fc4a5bdbf207 Mon Sep 17 00:00:00 2001 From: SotaTamura Date: Thu, 26 Feb 2026 19:32:57 +0900 Subject: [PATCH] =?UTF-8?q?=E6=8F=8F=E7=94=BB=E6=A9=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fonts/MyFlutterApp.ttf | Bin 0 -> 1844 bytes ios/Podfile.lock | 66 +++++ ios/Runner.xcodeproj/project.pbxproj | 18 ++ lib/features/map/models/drawing_path.dart | 26 ++ lib/features/map/presentation/map_screen.dart | 155 ++++++---- .../map/presentation/widgets/controls.dart | 279 ++++++++++++++++++ .../presentation/widgets/drawing_canvas.dart | 226 ++++++++++++++ .../map/providers/drawing_provider.dart | 93 ++++++ lib/icons/my_flutter_app_icons.dart | 35 +++ lib/main.dart | 6 +- pubspec.yaml | 8 +- 11 files changed, 848 insertions(+), 64 deletions(-) create mode 100644 fonts/MyFlutterApp.ttf create mode 100644 lib/features/map/models/drawing_path.dart create mode 100644 lib/features/map/presentation/widgets/controls.dart create mode 100644 lib/features/map/presentation/widgets/drawing_canvas.dart create mode 100644 lib/features/map/providers/drawing_provider.dart create mode 100644 lib/icons/my_flutter_app_icons.dart diff --git a/fonts/MyFlutterApp.ttf b/fonts/MyFlutterApp.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6b7ee5abf55bb6a9d96b5f832cd301831bb8ad4e GIT binary patch literal 1844 zcmd^A&u<$=7=5$rIH}#50#O>ZE}9mSwvgD4K`E39r34%fxF9#N1VW&#z5b>3?smPd z;)(+j9FT}a#Gw~@;J_g#E*zBDOP5w&lpjr91H`jrmtVV-FD{iUpHp6=T&;C2|BscQ<|zNp?l+x5 z!#VuTXMkC!-f7uZJ@?6-GVvz+e2c{Bd2@yMCi@euZn!#nn+W1(4r7j2voNn@^DPd^ zuC?lm44$NZlCpHIuD$W;`YGzU$}IR^FZ}-A$6o-WYEco4-K^|&IBqF|qsnHu84*La zb9d^>I}e?|hjeWF%eS|x$}pbyHq0$^oZ=V^yo0t=fz`%$}_5tnn z#>;gqY|n1bM)i2ptl&D$bTw4Qan@>Yo-{DS&gjIlwz)`FSofp&3 zoE7uMBVuBFYV^p{XU!jWe-%elhYqFgzjF9g`m1bK>>Mj+#dF8*U*Ea0`GI(T_qR;y zVk+}+`ljM{vf15R+49?e-grN9h(~Z&`vyv+;y45jQi`#`7pS7KN&XFu6U4U0Bm62{ z(Kt!|U5!(C3Lk2m#-sRB-JHY$`dPzw8VgK{ipB;qqNA}%epTZH@w&z%m=GHpC&_=U zaSB!OqsD1W8#9ZZKM2~*Rwz#|o{_~uab8{?NRL$8wH#^n!{^&>g`s~gpC9&*MR@QrK!7%yXd#5eX|iWH7x_&o^43UPriG+s zY}JMf3l13zeXeLlnjYl^_69YI4S9abzDBK!Tr_w2Rx1m|D80kS`$t!%m(sshUtnxL z?}yFU`qWt5aX+j5_-YhKkC7;^5_9xd*msvDTD#E-D~hnr6>;`GTxAr+D6>YCMZg`q yM5X`aBN?;Ob+}KB_PMC8A=z`t^Vu(dzMD}~-}VPJLm0xu7>~PW2ebV50e=BbMi=b> literal 0 HcmV?d00001 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index eb0fa59..a0bde07 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,27 +1,93 @@ PODS: + - AppAuth (1.7.6): + - AppAuth/Core (= 1.7.6) + - AppAuth/ExternalUserAgent (= 1.7.6) + - AppAuth/Core (1.7.6) + - AppAuth/ExternalUserAgent (1.7.6): + - AppAuth/Core + - AppCheckCore (11.2.0): + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) - Flutter (1.0.0) + - flutter_secure_storage (6.0.0): + - Flutter + - google_sign_in_ios (0.0.1): + - AppAuth (>= 1.7.4) + - Flutter + - FlutterMacOS + - GoogleSignIn (~> 8.0) + - GTMSessionFetcher (>= 3.4.0) + - GoogleSignIn (8.0.0): + - AppAuth (< 2.0, >= 1.7.3) + - AppCheckCore (~> 11.0) + - GTMAppAuth (< 5.0, >= 4.1.1) + - GTMSessionFetcher/Core (~> 3.3) + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMAppAuth (4.1.1): + - AppAuth/Core (~> 1.7) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher (3.5.0): + - GTMSessionFetcher/Full (= 3.5.0) + - GTMSessionFetcher/Core (3.5.0) + - GTMSessionFetcher/Full (3.5.0): + - GTMSessionFetcher/Core - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - PromisesObjC (2.4.0) - url_launcher_ios (0.0.1): - Flutter 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`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) +SPEC REPOS: + https://github.com/CocoaPods/Specs.git: + - AppAuth + - AppCheckCore + - GoogleSignIn + - GoogleUtilities + - GTMAppAuth + - GTMSessionFetcher + - PromisesObjC + EXTERNAL SOURCES: Flutter: :path: Flutter + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + google_sign_in_ios: + :path: ".symlinks/plugins/google_sign_in_ios/darwin" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: + AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 + AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + google_sign_in_ios: b48bb9af78576358a168361173155596c845f0b9 + GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b PODFILE CHECKSUM: 9ca1682d392c3b88ad6d313259873375a305cdca diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ad995c4..4dadb88 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -199,6 +199,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 822A1621A2D03696D9D1A291 /* [CP] Embed Pods Frameworks */, + 25F51009C59A158A4262BF07 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -270,6 +271,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 25F51009C59A158A4262BF07 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/lib/features/map/models/drawing_path.dart b/lib/features/map/models/drawing_path.dart new file mode 100644 index 0000000..2ecc6dd --- /dev/null +++ b/lib/features/map/models/drawing_path.dart @@ -0,0 +1,26 @@ +import 'dart:ui'; +import 'package:latlong2/latlong.dart'; + +class DrawingPath { + final List points; + final Color color; + final double strokeWidth; + + DrawingPath({ + required this.points, + required this.color, + required this.strokeWidth, + }); + + DrawingPath copyWith({ + List? points, + Color? color, + double? strokeWidth, + }) { + return DrawingPath( + points: points ?? this.points, + color: color ?? this.color, + strokeWidth: strokeWidth ?? this.strokeWidth, + ); + } +} diff --git a/lib/features/map/presentation/map_screen.dart b/lib/features/map/presentation/map_screen.dart index 63c5e69..f606fbd 100644 --- a/lib/features/map/presentation/map_screen.dart +++ b/lib/features/map/presentation/map_screen.dart @@ -6,6 +6,9 @@ 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/presentation/widgets/controls.dart'; class MapScreen extends ConsumerStatefulWidget { const MapScreen({super.key}); @@ -46,6 +49,7 @@ class _MapScreenState extends ConsumerState { final isAuthenticated = ref.watch(isAuthenticatedProvider); final user = ref.watch(currentUserProvider); final pinsAsync = ref.watch(pinsProvider); + final drawingState = ref.watch(drawingProvider); return Scaffold( appBar: AppBar( @@ -64,70 +68,107 @@ class _MapScreenState extends ConsumerState { ), IconButton( icon: Icon(isAuthenticated ? Icons.person : Icons.login), - onPressed: () => context.push(isAuthenticated ? '/profile' : '/login'), + onPressed: () => + context.push(isAuthenticated ? '/profile' : '/login'), tooltip: isAuthenticated ? 'Profile' : 'Login', ), ], ), - body: FlutterMap( - mapController: _mapController, - options: MapOptions( - initialCenter: const LatLng(35.6895, 139.6917), - initialZoom: 9.2, - interactionOptions: const InteractionOptions( - flags: InteractiveFlag.all & ~InteractiveFlag.doubleTapZoom, - ), - onTap: (tapPosition, latlng) { - ref.read(pinsProvider.notifier).addPin(latlng); - }, - ), - children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'dev.fleaflet.flutter_map.example', - ), - MarkerLayer( - markers: pinsAsync.when( - data: _buildMarkers, - loading: () => [], - error: (_, _) => [], - ), - ), - RichAttributionWidget( - alignment: AttributionAlignment.bottomLeft, - attributions: [ - TextSourceAttribution( - 'OpenStreetMap contributors', - onTap: () => - launchUrl(Uri.parse('https://openstreetmap.org/copyright')), - ), - ], - ), - ], - ), - floatingActionButton: Column( - mainAxisAlignment: MainAxisAlignment.end, + body: Column( 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, + 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); + } + }, + ), + 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: (_, _) => [], + ), + ), + RichAttributionWidget( + alignment: AttributionAlignment.bottomLeft, + attributions: [ + TextSourceAttribution( + 'OpenStreetMap contributors', + onTap: () => launchUrl( + Uri.parse('https://openstreetmap.org/copyright'), + ), + ), + ], + ), + ], + ), + 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), + ), + ], + ), + ), + ], ), - tooltip: 'Zoom out', - child: const Icon(Icons.remove), ), - const SizedBox(height: 8), + const Controls(), ], ), ); diff --git a/lib/features/map/presentation/widgets/controls.dart b/lib/features/map/presentation/widgets/controls.dart new file mode 100644 index 0000000..0b7d0fa --- /dev/null +++ b/lib/features/map/presentation/widgets/controls.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:memomap/features/map/providers/drawing_provider.dart'; +import 'package:memomap/icons/my_flutter_app_icons.dart'; + +class Controls extends ConsumerWidget { + const Controls({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final drawingState = ref.watch(drawingProvider); + final drawingNotifier = ref.read(drawingProvider.notifier); + + return Row( + children: [ + // ピンモードボタン + GestureDetector( + onTap: () => drawingNotifier.setDrawingMode(false), + child: Container( + padding: const EdgeInsets.all(16), + color: Colors.white.withValues(alpha: 0.9), + child: Column( + children: [ + Icon( + Icons.pin_drop, + size: 60, + color: !drawingState.isDrawingMode ? Colors.red : Colors.grey, + ), + ], + ), + ), + ), + // 描画モードコントロール(展開・折りたたみ) + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (Widget child, Animation animation) { + return FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + axisAlignment: -1, + child: child, + ), + ); + }, + child: drawingState.isDrawingMode + ? Container( + key: const ValueKey('expanded_controls'), + padding: const EdgeInsets.only(bottom: 24, top: 12), + color: Colors.white.withValues(alpha: 0.9), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // UndoButtonBar + OverflowBar( + alignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.undo_rounded), + tooltip: '元に戻す', + onPressed: () => drawingNotifier.undo(), + ), + IconButton( + icon: Icon( + MyFlutterApp.eraser_1, + color: drawingState.isEraserMode + ? Colors.blue + : Colors.black, + ), + tooltip: '消しゴム', + onPressed: () => drawingNotifier.setEraserMode( + !drawingState.isEraserMode, + ), + ), + ], + ), + // ColorSelectionWidget + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: + [ + Colors.red, + Colors.yellow, + Colors.green, + Colors.blue, + Colors.purple, + Colors.black, + ] + .asMap() + .entries + .map( + (entry) => _ColorCircle( + index: entry.key, + isSelected: + !drawingState.isEraserMode && + drawingState.selectedColor == + entry.value, + color: entry.value, + onTap: () => drawingNotifier + .selectColor(entry.value), + ), + ) + .toList(), + ), + const SizedBox(height: 10), + _StrokeWidthSlider( + color: drawingState.isEraserMode + ? Colors.grey + : drawingState.selectedColor, + width: drawingState.strokeWidth, + setWidth: (newWidth) => + drawingNotifier.changeStrokeWidth(newWidth), + ), + ], + ), + ], + ), + ) + : GestureDetector( + key: const ValueKey('collapsed_icon'), + onTap: () => drawingNotifier.setDrawingMode(true), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + color: Colors.white.withValues(alpha: 0.9), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.brush, size: 60, color: Colors.grey), + ], + ), + ), + ), + ), + ), + ], + ); + } +} + +class _ColorCircle extends StatelessWidget { + final int index; + final bool isSelected; + final Color color; + final VoidCallback onTap; + + const _ColorCircle({ + required this.index, + required this.isSelected, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 30, + height: 30, + transform: isSelected + ? Matrix4.diagonal3Values(1.2, 1.2, 1.0) + : Matrix4.identity(), + transformAlignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + border: Border.all( + color: isSelected ? Colors.black54 : Colors.white70, + width: isSelected ? 3 : 1, + ), + boxShadow: isSelected + ? [const BoxShadow(blurRadius: 4, color: Colors.black26)] + : null, + ), + ), + ); + } +} + +class _StrokeWidthSlider extends StatelessWidget { + final Color color; + final double width; + final ValueChanged setWidth; + + const _StrokeWidthSlider({ + required this.color, + required this.width, + required this.setWidth, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + alignment: Alignment.center, + children: [ + // 線の太さを視覚的に示す背景 + Container( + height: 12, + margin: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + gradient: LinearGradient( + colors: [ + Colors.grey.withValues(alpha: 0.2), + Colors.grey.withValues(alpha: 0.8), + ], + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CustomPaint( + size: const Size(double.infinity, 12), + painter: _TaperedBarPainter(color), + ), + ), + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: Colors.transparent, + inactiveTrackColor: Colors.transparent, + thumbColor: Colors.white, + overlayColor: Colors.black12, + trackHeight: 12, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 8, + elevation: 2, + ), + ), + child: Slider( + value: width, + min: 1, + max: 15, + onChanged: (value) => setWidth(value), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _TaperedBarPainter extends CustomPainter { + final Color color; + + _TaperedBarPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final path = Path() + ..moveTo(0, size.height * 0.4) + ..lineTo(size.width, size.height * 0.1) + ..lineTo(size.width, size.height * 0.9) + ..lineTo(0, size.height * 0.6) + ..close(); + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/lib/features/map/presentation/widgets/drawing_canvas.dart b/lib/features/map/presentation/widgets/drawing_canvas.dart new file mode 100644 index 0000000..6b10b20 --- /dev/null +++ b/lib/features/map/presentation/widgets/drawing_canvas.dart @@ -0,0 +1,226 @@ +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/features/map/providers/drawing_provider.dart b/lib/features/map/providers/drawing_provider.dart new file mode 100644 index 0000000..cce3d92 --- /dev/null +++ b/lib/features/map/providers/drawing_provider.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:memomap/features/map/models/drawing_path.dart'; + +class DrawingState { + final List paths; + final Color selectedColor; + final double strokeWidth; + final bool isDrawingMode; + final bool isEraserMode; + + DrawingState({ + required this.paths, + required this.selectedColor, + required this.strokeWidth, + required this.isDrawingMode, + this.isEraserMode = false, + }); + + DrawingState copyWith({ + List? paths, + Color? selectedColor, + double? strokeWidth, + bool? isDrawingMode, + bool? isEraserMode, + }) { + return DrawingState( + paths: paths ?? this.paths, + selectedColor: selectedColor ?? this.selectedColor, + strokeWidth: strokeWidth ?? this.strokeWidth, + isDrawingMode: isDrawingMode ?? this.isDrawingMode, + isEraserMode: isEraserMode ?? this.isEraserMode, + ); + } +} + +class DrawingNotifier extends Notifier { + @override + DrawingState build() { + return DrawingState( + paths: [], + selectedColor: Colors.red, + strokeWidth: 3, + isDrawingMode: false, + ); + } + + void toggleDrawingMode() { + state = state.copyWith(isDrawingMode: !state.isDrawingMode); + } + + void setDrawingMode(bool value) { + state = state.copyWith(isDrawingMode: value); + } + + void setEraserMode(bool value) { + state = state.copyWith(isEraserMode: value); + } + + void addPath(DrawingPath path) { + state = state.copyWith(paths: [...state.paths, path]); + } + + void setPaths(List paths) { + state = state.copyWith(paths: paths); + } + + void removePathAt(int index) { + final newPaths = List.from(state.paths); + newPaths.removeAt(index); + state = state.copyWith(paths: newPaths); + } + + void undo() { + if (state.paths.isNotEmpty) { + state = state.copyWith( + paths: state.paths.sublist(0, state.paths.length - 1), + ); + } + } + + void selectColor(Color color) { + state = state.copyWith(selectedColor: color, isEraserMode: false); + } + + void changeStrokeWidth(double width) { + state = state.copyWith(strokeWidth: width); + } +} + +final drawingProvider = NotifierProvider(() { + return DrawingNotifier(); +}); diff --git a/lib/icons/my_flutter_app_icons.dart b/lib/icons/my_flutter_app_icons.dart new file mode 100644 index 0000000..9c03e76 --- /dev/null +++ b/lib/icons/my_flutter_app_icons.dart @@ -0,0 +1,35 @@ +/// Flutter icons MyFlutterApp +/// Copyright (C) 2026 by original authors @ fluttericon.com, fontello.com +/// This font was generated by FlutterIcon.com, which is derived from Fontello. +/// +/// To use this font, place it in your fonts/ directory and include the +/// following in your pubspec.yaml +/// +/// flutter: +/// fonts: +/// - family: MyFlutterApp +/// fonts: +/// - asset: fonts/MyFlutterApp.ttf +/// +/// +/// * Font Awesome 5, Copyright (C) 2016 by Dave Gandy +/// Author: Dave Gandy +/// License: SIL (https://github.com/FortAwesome/Font-Awesome/blob/master/LICENSE.txt) +/// Homepage: http://fortawesome.github.com/Font-Awesome/ +/// +library; + +import 'package:flutter/widgets.dart'; + +class MyFlutterApp { + MyFlutterApp._(); + + static const _kFontFam = 'MyFlutterApp'; + static const String? _kFontPkg = null; + + static const IconData eraser_1 = IconData( + 0xf12d, + fontFamily: _kFontFam, + fontPackage: _kFontPkg, + ); +} diff --git a/lib/main.dart b/lib/main.dart index 371cbb4..8c66d1c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,9 +7,5 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: '.env'); - runApp( - const ProviderScope( - child: App(), - ), - ); + runApp(const ProviderScope(child: App())); } diff --git a/pubspec.yaml b/pubspec.yaml index 210a768..be4e188 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: memomap description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -73,7 +73,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. @@ -107,3 +106,8 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + + fonts: + - family: MyFlutterApp + fonts: + - asset: fonts/MyFlutterApp.ttf