diff --git a/docs/superpowers/plans/2026-04-02-chat-location-share.md b/docs/superpowers/plans/2026-04-02-chat-location-share.md new file mode 100644 index 00000000..49379934 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-chat-location-share.md @@ -0,0 +1,835 @@ +# 채팅 위치 공유 기능 구현 플랜 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 채팅방에서 위치를 선택해 지도 말풍선으로 전송하고, 수신자는 탭하여 네이버지도에서 해당 위치를 확인할 수 있다. + +**Architecture:** `ChatLocationPickerScreen`(위치 선택)이 `LocationAddress`를 반환하면 `ChatRoomScreen`이 WebSocket으로 LOCATION 타입 메시지를 전송한다. 수신 측은 `ChatLocationBubble`이 Naver Static Maps API 이미지 + 주소 텍스트 + 딥링크 버튼으로 렌더링한다. + +**Tech Stack:** flutter_naver_map, geolocator, url_launcher, http, flutter_dotenv, flutter_screenutil + +--- + +## 파일 맵 + +| 역할 | 파일 | 신규/수정 | +|---|---|---| +| MessageType enum | `lib/enums/message_type.dart` | 수정 | +| ChatMessage 모델 | `lib/models/apis/objects/chat_message.dart` | 수정 | +| ChatMessage generated | `lib/models/apis/objects/chat_message.g.dart` | 수정 | +| App URL 상수 | `lib/models/app_urls.dart` | 수정 | +| WebSocket 전송 | `lib/services/chat_websocket_service.dart` | 수정 | +| 위치 선택 화면 | `lib/screens/chat_location_picker_screen.dart` | 신규 | +| 위치 말풍선 위젯 | `lib/widgets/chat_location_bubble.dart` | 신규 | +| 채팅 입력 바 | `lib/widgets/chat_input_bar.dart` | 수정 | +| 메시지 아이템 | `lib/widgets/chat_message_item.dart` | 수정 | +| 채팅방 화면 | `lib/screens/chat_room_screen.dart` | 수정 | + +--- + +## Task 1: MessageType + ChatMessage 모델 업데이트 + +**Files:** +- Modify: `lib/enums/message_type.dart` +- Modify: `lib/models/apis/objects/chat_message.dart` +- Modify: `lib/models/apis/objects/chat_message.g.dart` +- Modify: `lib/models/app_urls.dart` + +- [ ] **Step 1: MessageType에 LOCATION 추가** + +`lib/enums/message_type.dart` 전체 교체: +```dart +import 'package:json_annotation/json_annotation.dart'; + +/// 채팅 메시지 타입 (백엔드 MessageType Enum) +enum MessageType { + @JsonValue('TEXT') + text, + @JsonValue('IMAGE') + image, + @JsonValue('SYSTEM') + system, + @JsonValue('LOCATION') + location, +} +``` + +- [ ] **Step 2: ChatMessage 모델에 위치 필드 추가** + +`lib/models/apis/objects/chat_message.dart`에서 필드·생성자·copyWith 수정: +```dart +// lib/models/apis/objects/chat_message.dart +import 'package:json_annotation/json_annotation.dart'; +import 'package:romrom_fe/enums/message_type.dart'; +import 'package:romrom_fe/models/apis/objects/base_entity.dart'; + +part 'chat_message.g.dart'; + +/// 채팅 메시지 모델 (MongoDB) +@JsonSerializable() +class ChatMessage extends BaseEntity { + final String? chatMessageId; + final String? chatRoomId; + final String? senderId; + final String? recipientId; + final String? content; + final List? imageUrls; + final MessageType? type; + final bool? isProfanityDetected; + final double? latitude; + final double? longitude; + final String? address; + + ChatMessage({ + super.createdDate, + super.updatedDate, + this.chatMessageId, + this.chatRoomId, + this.senderId, + this.recipientId, + this.content, + this.imageUrls, + this.type, + this.isProfanityDetected, + this.latitude, + this.longitude, + this.address, + }); + + factory ChatMessage.fromJson(Map json) => _$ChatMessageFromJson(json); + @override + Map toJson() => _$ChatMessageToJson(this); +} + +/// ChatMessage 복사 및 수정용 확장 메서드 +extension ChatMessageCopy on ChatMessage { + ChatMessage copyWith({ + String? chatMessageId, + String? chatRoomId, + String? senderId, + String? recipientId, + String? content, + List? imageUrls, + MessageType? type, + bool? isProfanityDetected, + DateTime? createdDate, + DateTime? updatedDate, + double? latitude, + double? longitude, + String? address, + }) => ChatMessage( + chatMessageId: chatMessageId ?? this.chatMessageId, + chatRoomId: chatRoomId ?? this.chatRoomId, + senderId: senderId ?? this.senderId, + recipientId: recipientId ?? this.recipientId, + content: content ?? this.content, + imageUrls: imageUrls ?? this.imageUrls, + type: type ?? this.type, + isProfanityDetected: isProfanityDetected ?? this.isProfanityDetected, + createdDate: createdDate ?? this.createdDate, + updatedDate: updatedDate ?? this.updatedDate, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + address: address ?? this.address, + ); +} +``` + +- [ ] **Step 3: .g.dart 파일 수동 업데이트** + +`lib/models/apis/objects/chat_message.g.dart` 전체 교체: +```dart +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_message.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChatMessage _$ChatMessageFromJson(Map json) => ChatMessage( + createdDate: json['createdDate'] == null ? null : DateTime.parse(json['createdDate'] as String), + updatedDate: json['updatedDate'] == null ? null : DateTime.parse(json['updatedDate'] as String), + chatMessageId: json['chatMessageId'] as String?, + chatRoomId: json['chatRoomId'] as String?, + senderId: json['senderId'] as String?, + recipientId: json['recipientId'] as String?, + content: json['content'] as String?, + imageUrls: (json['imageUrls'] as List?)?.map((e) => e as String).toList(), + type: $enumDecodeNullable(_$MessageTypeEnumMap, json['type']), + isProfanityDetected: json['isProfanityDetected'] as bool?, + latitude: (json['latitude'] as num?)?.toDouble(), + longitude: (json['longitude'] as num?)?.toDouble(), + address: json['address'] as String?, +); + +Map _$ChatMessageToJson(ChatMessage instance) => { + 'createdDate': instance.createdDate?.toIso8601String(), + 'updatedDate': instance.updatedDate?.toIso8601String(), + 'chatMessageId': instance.chatMessageId, + 'chatRoomId': instance.chatRoomId, + 'senderId': instance.senderId, + 'recipientId': instance.recipientId, + 'content': instance.content, + 'imageUrls': instance.imageUrls, + 'type': _$MessageTypeEnumMap[instance.type], + 'isProfanityDetected': instance.isProfanityDetected, + 'latitude': instance.latitude, + 'longitude': instance.longitude, + 'address': instance.address, +}; + +const _$MessageTypeEnumMap = { + MessageType.text: 'TEXT', + MessageType.image: 'IMAGE', + MessageType.system: 'SYSTEM', + MessageType.location: 'LOCATION', +}; +``` + +- [ ] **Step 4: AppUrls에 Static Maps URL 추가** + +`lib/models/app_urls.dart`에 한 줄 추가: +```dart +static const String naverStaticMapApiUrl = + 'https://naveropenapi.apigw.ntruss.com/map-static/v2/raster'; +``` + +- [ ] **Step 5: flutter analyze 확인** + +```bash +source ~/.zshrc && flutter analyze +``` +Expected: No issues found. + +- [ ] **Step 6: 커밋** + +```bash +git add lib/enums/message_type.dart \ + lib/models/apis/objects/chat_message.dart \ + lib/models/apis/objects/chat_message.g.dart \ + lib/models/app_urls.dart +git commit -m "feat: ChatMessage에 LOCATION 타입 및 위치 필드 추가 (#이슈번호)" +``` + +--- + +## Task 2: ChatWebSocketService 위치 전송 지원 + +**Files:** +- Modify: `lib/services/chat_websocket_service.dart:241-272` + +- [ ] **Step 1: sendMessage 시그니처 및 payload 확장** + +`sendMessage` 메서드를 아래로 교체: +```dart +/// 메시지 전송 +void sendMessage({ + required String chatRoomId, + required String content, + MessageType type = MessageType.text, + List? imageUrls, + double? latitude, + double? longitude, + String? address, +}) { + if (type == MessageType.image && (imageUrls == null || imageUrls.isEmpty)) { + throw Exception('imageUrls is required for image messages'); + } + if (type == MessageType.location && (latitude == null || longitude == null)) { + throw Exception('latitude and longitude are required for location messages'); + } + if (!_isConnected || _stompClient == null) { + debugPrint('[WebSocket] Cannot send message: Not connected'); + throw Exception('STOMP not connected'); + } + + final Map payload = { + 'chatRoomId': chatRoomId, + 'content': content, + 'type': type.toString().split('.').last.toUpperCase(), + }; + + if (type == MessageType.image && imageUrls != null) { + payload['imageUrls'] = imageUrls; + } + + if (type == MessageType.location) { + payload['latitude'] = latitude; + payload['longitude'] = longitude; + payload['address'] = address; + } + + debugPrint('[WebSocket] Sending message to /app/chat.send\n$payload'); + _stompClient!.send( + destination: '/app/chat.send', + body: jsonEncode(payload), + headers: {'content-type': 'application/json'}, + ); +} +``` + +- [ ] **Step 2: flutter analyze 확인** + +```bash +source ~/.zshrc && flutter analyze +``` +Expected: No issues found. + +- [ ] **Step 3: 커밋** + +```bash +git add lib/services/chat_websocket_service.dart +git commit -m "feat: WebSocket sendMessage에 LOCATION 타입 지원 추가 (#이슈번호)" +``` + +--- + +## Task 3: ChatLocationPickerScreen 구현 + +**Files:** +- Create: `lib/screens/chat_location_picker_screen.dart` + +- [ ] **Step 1: 화면 파일 생성** + +`lib/screens/chat_location_picker_screen.dart` 신규 생성: +```dart +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_naver_map/flutter_naver_map.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:romrom_fe/enums/snack_bar_type.dart'; +import 'package:romrom_fe/models/app_colors.dart'; +import 'package:romrom_fe/models/location_address.dart'; +import 'package:romrom_fe/services/location_service.dart'; +import 'package:romrom_fe/utils/device_type.dart'; +import 'package:romrom_fe/widgets/common/common_snack_bar.dart'; +import 'package:romrom_fe/widgets/common/completion_button.dart'; +import 'package:romrom_fe/widgets/common/current_location_button.dart'; +import 'package:romrom_fe/widgets/common_app_bar.dart'; +import 'package:shadex/shadex.dart'; + +/// 채팅 위치 보내기 - 위치 선택 화면 +/// 선택 완료 시 Navigator.pop(context, LocationAddress)로 반환 +class ChatLocationPickerScreen extends StatefulWidget { + const ChatLocationPickerScreen({super.key}); + + @override + State createState() => _ChatLocationPickerScreenState(); +} + +class _ChatLocationPickerScreenState extends State { + final _locationService = LocationService(); + NLatLng? _currentPosition; + NLatLng? _selectedPosition; + LocationAddress? _selectedAddress; + final Completer _mapControllerCompleter = Completer(); + bool _isSending = false; + + @override + void initState() { + super.initState(); + _initializeLocation(); + } + + Future _initializeLocation() async { + final hasPermission = await _locationService.requestPermission(); + if (!hasPermission) { + const seoulCityHall = NLatLng(37.5665, 126.9780); + setState(() => _currentPosition = seoulCityHall); + await _updateAddress(seoulCityHall); + return; + } + final position = await _locationService.getCurrentPosition(); + if (position != null) { + final latLng = _locationService.positionToLatLng(position); + setState(() => _currentPosition = latLng); + await _updateAddress(latLng); + } else { + const seoulCityHall = NLatLng(37.5665, 126.9780); + setState(() => _currentPosition = seoulCityHall); + await _updateAddress(seoulCityHall); + } + } + + Future _updateAddress(NLatLng position) async { + final address = await _locationService.getAddressFromCoordinates(position); + if (address != null && mounted) { + setState(() { + _selectedPosition = position; + _selectedAddress = address; + }); + } + } + + Future _onSend() async { + if (_isSending || _selectedAddress == null || _selectedPosition == null) return; + setState(() => _isSending = true); + try { + final result = LocationAddress( + siDo: _selectedAddress!.siDo, + siGunGu: _selectedAddress!.siGunGu, + eupMyoenDong: _selectedAddress!.eupMyoenDong, + ri: _selectedAddress!.ri, + latitude: _selectedPosition!.latitude, + longitude: _selectedPosition!.longitude, + ); + if (mounted) Navigator.pop(context, result); + } catch (e) { + if (mounted) { + CommonSnackBar.show(context: context, message: '위치 전송에 실패했습니다: $e', type: SnackBarType.error); + } + } finally { + if (mounted) setState(() => _isSending = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CommonAppBar(title: '위치 보내기'), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + children: [ + // 네이버 지도 + Positioned.fill( + child: _currentPosition == null + ? const Center(child: CircularProgressIndicator(color: AppColors.primaryYellow)) + : NaverMap( + options: NaverMapViewOptions( + initialCameraPosition: NCameraPosition(target: _currentPosition!, zoom: 15), + logoAlign: NLogoAlign.leftBottom, + logoMargin: NEdgeInsets.fromEdgeInsets(EdgeInsets.only(left: 24.w, bottom: 137.h)), + indoorEnable: true, + locationButtonEnable: false, + ), + onMapReady: (controller) async { + if (!_mapControllerCompleter.isCompleted) { + _mapControllerCompleter.complete(controller); + controller.setLocationTrackingMode(NLocationTrackingMode.noFollow); + } + }, + onCameraIdle: () async { + final controller = await _mapControllerCompleter.future; + final position = await controller.getCameraPosition(); + await _updateAddress(position.target); + }, + ), + ), + // 중앙 핀 + Center( + child: Container( + margin: EdgeInsets.only(bottom: 40.h), + child: Shadex( + shadowColor: AppColors.opacity20Black, + shadowBlurRadius: 2.0, + shadowOffset: const Offset(2, 2), + child: SvgPicture.asset('assets/images/location-pin.svg'), + ), + ), + ), + // 보내기 버튼 + Positioned( + left: 24.w, + right: 24.w, + bottom: 57.h, + child: CompletionButton( + isEnabled: _selectedAddress != null, + isLoading: _isSending, + buttonText: '보내기', + enabledOnPressed: _onSend, + ), + ), + // 현재 위치 버튼 + Positioned( + bottom: isTablet ? 200 : 160.h, + left: 24.w, + child: CurrentLocationButton( + onTap: () async { + final controller = await _mapControllerCompleter.future; + final position = await _locationService.getCurrentPosition(); + if (position != null && mounted) { + final newPosition = _locationService.positionToLatLng(position); + await controller.updateCamera( + NCameraUpdate.fromCameraPosition(NCameraPosition(target: newPosition, zoom: 15)), + ); + controller.setLocationTrackingMode(NLocationTrackingMode.noFollow); + setState(() => _currentPosition = newPosition); + await _updateAddress(newPosition); + } + }, + iconSize: 24.h, + ), + ), + ], + ), + ), + ], + ), + ); + } +} +``` + +- [ ] **Step 2: flutter analyze 확인** + +```bash +source ~/.zshrc && flutter analyze +``` +Expected: No issues found. + +- [ ] **Step 3: 커밋** + +```bash +git add lib/screens/chat_location_picker_screen.dart +git commit -m "feat: 채팅 위치 선택 화면(ChatLocationPickerScreen) 추가 (#이슈번호)" +``` + +--- + +## Task 4: ChatLocationBubble 위젯 구현 + +**Files:** +- Create: `lib/widgets/chat_location_bubble.dart` + +- [ ] **Step 1: 위젯 파일 생성** + +`lib/widgets/chat_location_bubble.dart` 신규 생성: +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:romrom_fe/models/apis/objects/chat_message.dart'; +import 'package:romrom_fe/models/app_colors.dart'; +import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/models/app_urls.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// LOCATION 타입 메시지 말풍선 +/// - Static Maps API 이미지 (264w × 160h) +/// - 주소 텍스트 +/// - "지도에서 보기" 버튼 → 네이버지도 앱 or 웹 폴백 +class ChatLocationBubble extends StatelessWidget { + final ChatMessage message; + + const ChatLocationBubble({super.key, required this.message}); + + String? _buildStaticMapUrl() { + final lat = message.latitude; + final lng = message.longitude; + if (lat == null || lng == null) return null; + final center = '$lng,$lat'; + final markerPos = Uri.encodeComponent('$lng $lat'); + return '${AppUrls.naverStaticMapApiUrl}' + '?w=264&h=160' + '¢er=$center' + '&level=15' + '&markers=type:d|size:mid|pos:$markerPos'; + } + + Future _openNaverMap() async { + final lat = message.latitude; + final lng = message.longitude; + if (lat == null || lng == null) return; + + final appUri = Uri.parse('nmap://map?lat=$lat&lng=$lng&zoom=15&appname=com.alom.romrom'); + final webUri = Uri.parse('https://map.naver.com/v5/?c=$lng,$lat,15,0,0,0,dh'); + + if (await canLaunchUrl(appUri)) { + await launchUrl(appUri); + } else { + await launchUrl(webUri, mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context) { + final staticMapUrl = _buildStaticMapUrl(); + final address = message.address ?? '위치'; + + return ClipRRect( + borderRadius: BorderRadius.circular(10.r), + child: Container( + width: 220, + decoration: BoxDecoration( + color: AppColors.secondaryBlack1, + borderRadius: BorderRadius.circular(10.r), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 지도 이미지 + SizedBox( + width: 220, + height: 130, + child: staticMapUrl != null + ? Image.network( + staticMapUrl, + width: 220, + height: 130, + fit: BoxFit.cover, + headers: { + 'X-NCP-APIGW-API-KEY-ID': dotenv.get('NMF_CLIENT_ID'), + 'X-NCP-APIGW-API-KEY': dotenv.get('NMF_CLIENT_SECRET'), + }, + errorBuilder: (_, __, ___) => Container( + color: AppColors.secondaryBlack2, + child: const Center( + child: Icon(Icons.map_outlined, color: AppColors.opacity50White, size: 32), + ), + ), + loadingBuilder: (_, child, progress) { + if (progress == null) return child; + return Container( + color: AppColors.secondaryBlack2, + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primaryYellow, + ), + ), + ), + ); + }, + ) + : Container( + color: AppColors.secondaryBlack2, + child: const Center( + child: Icon(Icons.map_outlined, color: AppColors.opacity50White, size: 32), + ), + ), + ), + // 주소 텍스트 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text( + address, + style: CustomTextStyles.p3.copyWith( + color: AppColors.textColorWhite, + fontWeight: FontWeight.w400, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + // 구분선 + Container(height: 1, color: AppColors.opacity10White), + // 지도에서 보기 버튼 + GestureDetector( + onTap: _openNaverMap, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + alignment: Alignment.center, + child: Text( + '지도에서 보기', + style: CustomTextStyles.p3.copyWith( + color: AppColors.primaryYellow, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 2: flutter analyze 확인** + +```bash +source ~/.zshrc && flutter analyze +``` +Expected: No issues found. + +- [ ] **Step 3: 커밋** + +```bash +git add lib/widgets/chat_location_bubble.dart +git commit -m "feat: 채팅 위치 말풍선(ChatLocationBubble) 위젯 추가 (#이슈번호)" +``` + +--- + +## Task 5: ChatInputBar 위치 메뉴 항목 추가 + +**Files:** +- Modify: `lib/widgets/chat_input_bar.dart` + +- [ ] **Step 1: onSendLocation 콜백 파라미터 추가 및 메뉴 항목 추가** + +`ChatInputBar` 클래스 수정: + +생성자 파라미터에 추가: +```dart +final VoidCallback onSendLocation; +``` + +`const ChatInputBar({...})` required 목록에 추가: +```dart +required this.onSendLocation, +``` + +`items` 리스트에 위치 항목 추가 (기존 `select_photo` 항목 다음): +```dart +ContextMenuItem( + id: 'send_location', + icon: AppIcons.location, + iconColor: AppColors.opacity60White, + title: '위치 보내기', + onTap: () => onSendLocation(), +), +``` + +- [ ] **Step 2: flutter analyze 확인** + +```bash +source ~/.zshrc && flutter analyze +``` +Expected: No issues found. + +- [ ] **Step 3: 커밋** + +```bash +git add lib/widgets/chat_input_bar.dart +git commit -m "feat: ChatInputBar에 위치 보내기 메뉴 항목 추가 (#이슈번호)" +``` + +--- + +## Task 6: ChatMessageItem LOCATION 타입 처리 + +**Files:** +- Modify: `lib/widgets/chat_message_item.dart` + +- [ ] **Step 1: import 추가 + _buildBubble에 LOCATION 분기 추가** + +파일 상단 import에 추가: +```dart +import 'package:romrom_fe/widgets/chat_location_bubble.dart'; +``` + +`_buildBubble` 메서드 내 `if (message.type == MessageType.image)` 블록 바로 위에 추가: +```dart +if (message.type == MessageType.location) { + return ChatLocationBubble(message: message); +} +``` + +- [ ] **Step 2: flutter analyze 확인** + +```bash +source ~/.zshrc && flutter analyze +``` +Expected: No issues found. + +- [ ] **Step 3: 커밋** + +```bash +git add lib/widgets/chat_message_item.dart +git commit -m "feat: ChatMessageItem에 LOCATION 타입 말풍선 렌더링 추가 (#이슈번호)" +``` + +--- + +## Task 7: ChatRoomScreen 연결 + +**Files:** +- Modify: `lib/screens/chat_room_screen.dart` + +- [ ] **Step 1: import 추가** + +`chat_room_screen.dart` import 목록에 추가: +```dart +import 'package:romrom_fe/models/location_address.dart'; +import 'package:romrom_fe/screens/chat_location_picker_screen.dart'; +``` + +- [ ] **Step 2: _onSendLocation 핸들러 추가** + +`_onPickImage` 메서드 바로 아래에 추가: +```dart +Future _onSendLocation() async { + if (_isInputDisabled) return; + FocusScope.of(context).unfocus(); + + final LocationAddress? result = await context.navigateTo( + screen: const ChatLocationPickerScreen(), + ); + + if (result == null || !mounted) return; + + final lat = result.latitude; + final lng = result.longitude; + if (lat == null || lng == null) return; + + final address = [result.siDo, result.siGunGu, result.eupMyoenDong] + .where((s) => s.isNotEmpty) + .join(' '); + + _wsService.sendMessage( + chatRoomId: widget.chatRoomId, + content: address, + type: MessageType.location, + latitude: lat, + longitude: lng, + address: address, + ); +} +``` + +- [ ] **Step 3: ChatInputBar에 onSendLocation 연결** + +`build` 메서드 내 `ChatInputBar(...)` 위젯에 파라미터 추가: +```dart +onSendLocation: _onSendLocation, +``` + +- [ ] **Step 4: flutter analyze 확인** + +```bash +source ~/.zshrc && flutter analyze +``` +Expected: No issues found. + +- [ ] **Step 5: 포매팅** + +```bash +source ~/.zshrc && dart format --line-length=120 . +``` + +- [ ] **Step 6: 최종 커밋** + +```bash +git add lib/screens/chat_room_screen.dart +git commit -m "feat: 채팅방 위치 보내기 기능 연결 완료 (#이슈번호) [skip ci]" +``` + +--- + +## 수동 검증 체크리스트 + +구현 완료 후 기기에서 직접 확인: + +- [ ] `+` 버튼 탭 → "위치 보내기" 메뉴 항목 노출 +- [ ] "위치 보내기" 탭 → 전체화면 지도 진입, 현재 위치로 초기화 +- [ ] 지도 이동 → 하단 주소 텍스트 업데이트 +- [ ] 현재 위치 버튼 → 지도가 현재 위치로 이동 +- [ ] "보내기" 탭 → 채팅방으로 복귀, 위치 말풍선 노출 +- [ ] 말풍선에 Static Maps 이미지 + 주소 텍스트 표시 +- [ ] "지도에서 보기" 탭 → 네이버지도 앱 실행 (앱 있을 때) +- [ ] "지도에서 보기" 탭 → 웹 브라우저 열림 (앱 없을 때) +- [ ] 상대방 수신 측에서도 말풍선 정상 렌더링 +- [ ] 위치 권한 거부 시 서울시청 폴백 후 정상 동작 +- [ ] iPad에서 말풍선 overflow 없음 diff --git a/docs/superpowers/specs/2026-04-02-chat-location-share-design.md b/docs/superpowers/specs/2026-04-02-chat-location-share-design.md new file mode 100644 index 00000000..5de981a2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-chat-location-share-design.md @@ -0,0 +1,131 @@ +# 채팅 위치 공유 기능 설계 + +**날짜:** 2026-04-02 +**이슈:** 채팅방 위치 보내기 기능 + +--- + +## 개요 + +채팅방에서 본인의 위치를 지도 말풍선 형태로 전송하는 기능. +백엔드와 `MessageType.LOCATION`으로 소통하며, Naver Static Maps API로 지도 썸네일을 렌더링한다. + +--- + +## 유저 플로우 + +1. 채팅 입력 바 `+` 버튼 탭 → 컨텍스트 메뉴 "위치 보내기" 선택 +2. `ChatLocationPickerScreen` 진입 — 현재 위치로 초기화된 전체 화면 네이버 지도 +3. 지도를 움직여 원하는 위치 선택 → "보내기" 버튼 +4. `Navigator.pop`으로 `LocationAddress` 반환 → `ChatRoomScreen`에서 WebSocket 전송 +5. 채팅창에 위치 말풍선 노출 +6. 말풍선 "지도에서 보기" 버튼 → 네이버지도 앱 실행 (없으면 웹) + +--- + +## 데이터 변경 + +### `MessageType` (lib/enums/message_type.dart) +```dart +@JsonValue('LOCATION') +location, +``` + +### `ChatMessage` (lib/models/apis/objects/chat_message.dart) +필드 3개 추가: +```dart +final double? latitude; +final double? longitude; +final String? address; +``` +`build_runner`로 `.g.dart` 재생성 필요. + +### `ChatWebSocketService.sendMessage` +LOCATION 타입일 때 payload에 포함: +```json +{ + "chatRoomId": "...", + "type": "LOCATION", + "latitude": 37.5665, + "longitude": 126.9780, + "address": "서울특별시 광진구 능동로 209" +} +``` + +--- + +## 새 파일 + +### 1. `lib/screens/chat_location_picker_screen.dart` + +`ItemRegisterLocationScreen`과 동일한 패턴: +- 초기 위치: 현재 위치 (권한 없으면 서울시청 폴백) +- 중앙 핀 SVG + 카메라 idle 시 역지오코딩 +- 하단 "보내기" 버튼: `Navigator.pop(context, selectedAddress)` +- 커스텀 `CurrentLocationButton` + +### 2. `lib/widgets/chat_location_bubble.dart` + +말풍선 구조 (264.w 고정폭): +``` +┌────────────────────────┐ +│ Naver Static Map │ Image.network (264w × 160h) +│ 이미지 (마커 포함) │ +├────────────────────────┤ +│ 주소 텍스트 │ padding 12w/10h +├────────────────────────┤ +│ 지도에서 보기 │ GestureDetector 버튼 +└────────────────────────┘ +``` + +**Static Maps API:** +``` +URL: https://naveropenapi.apigw.ntruss.com/map-static/v2/raster + ?w=264&h=160 + ¢er={lng},{lat} + &level=15 + &markers=type:d|size:mid|pos:{lng}%20{lat} +Headers: + X-NCP-APIGW-API-KEY-ID: {NMF_CLIENT_ID} + X-NCP-APIGW-API-KEY: {NMF_CLIENT_SECRET} +``` +`Image.network`의 `headers` 파라미터로 인증. +로딩 중: shimmer 또는 Container(color: AppColors.secondaryBlack1) +에러 시: 주소 텍스트만 표시 + +**"지도에서 보기" 딥링크:** +```dart +// 앱 +nmap://map?lat={lat}&lng={lng}&zoom=15&appname=com.alom.romrom +// 웹 폴백 +https://map.naver.com/v5/?c={lng},{lat},15,0,0,0,dh +``` + +--- + +## 수정 파일 + +### `lib/widgets/chat_input_bar.dart` +- `onSendLocation: VoidCallback` 파라미터 추가 +- `ContextMenuItem` 리스트에 위치 항목 추가 + +### `lib/widgets/chat_message_item.dart` +- `_buildBubble`에 LOCATION 타입 분기: + ```dart + if (message.type == MessageType.location) { + return ChatLocationBubble(message: message); + } + ``` + +### `lib/screens/chat_room_screen.dart` +- `_onSendLocation()` 핸들러 추가 +- `ChatLocationPickerScreen` push 후 결과 받아 WebSocket 전송 + +--- + +## 고려사항 + +- **Static Maps API**: NCP 콘솔에서 "Maps Static API" 서비스 활성화 필요 +- **iPad 대응**: 말풍선 고정 픽셀값 사용 (`.w` 최소화) +- **에러 처리**: 위치 권한 거부 시 서울시청 폴백 (기존 패턴 동일) +- **중복 전송 방지**: `_pendingRequests` 패턴 적용 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b73f31bc..5c16e725 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -266,11 +266,11 @@ SPEC CHECKSUMS: AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 - device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d - firebase_auth: e9031a1dbe04a90d98e8d11ff2302352a1c6d9e8 - firebase_core: ee30637e6744af8e0c12a6a1e8a9718506ec2398 - firebase_messaging: 343de01a8d3e18b60df0c6d37f7174c44ae38e02 + firebase_auth: e7aec07fcada64e296cf237a61df9660e52842c2 + firebase_core: 8d5e24676350f15dd111aa59a88a1ae26605f9ba + firebase_messaging: 834cfc0887393d3108cdb19da8e57655c54fd0e4 FirebaseAppCheckInterop: ba3dc604a89815379e61ec2365101608d365cf7d FirebaseAuth: 4c289b1a43f5955283244a55cf6bd616de344be5 FirebaseAuthInterop: 95363fe96493cb4f106656666a0768b420cba090 @@ -280,34 +280,34 @@ SPEC CHECKSUMS: FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0 FirebaseMessaging: 7f42cfd10ec64181db4e01b305a613791c8e782c Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb - flutter_naver_map: d9f447f1271059759d4d5e38c3b347d7ab2ba835 - flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 - geocoding_ios: 33776c9ebb98d037b5e025bb0e7537f6dd19646e - geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e - google_sign_in_ios: 000870aa06da9b28d1d0bf7ef70ff0213059dd28 + flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f + flutter_naver_map: a1f6df83f663d9770e01624ba97a2d34c4fe349b + flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d + geocoding_ios: eafacae6ad11a1eb56681f7d11df602a5fd49416 + geolocator_apple: 66b711889fd333205763b83c9dcf0a57a28c7afd + google_sign_in_ios: 7336a3372ea93ea56a21e126a0055ffca3723601 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 - image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - kakao_flutter_sdk_common: 682b3606698f87467788598dc2dc09d4e6867fbd + image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + kakao_flutter_sdk_common: a21740b9dd4900f96161f365a2b6ece7a97cbf4f nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NMapsGeometry: 4e02554fa9880ef02ed96b075dc84355d6352479 NMapsMap: 1964e6f9073301ad3cbe3a12235ba36f6f6cd905 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 - patrol: cea8074f183a2a4232d0ebd10569ae05149ada42 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba + patrol: d49fcd015892f19189a4cec572f21f3c3358e761 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa + webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 PODFILE CHECKSUM: ce2a4dd764e1c7aeed6a7cdc5e61d092b6dc6d32 diff --git a/lib/enums/message_type.dart b/lib/enums/message_type.dart index dfc9a7a0..a555f054 100644 --- a/lib/enums/message_type.dart +++ b/lib/enums/message_type.dart @@ -8,4 +8,6 @@ enum MessageType { image, @JsonValue('SYSTEM') system, + @JsonValue('LOCATION') + location, } diff --git a/lib/models/apis/objects/chat_message.dart b/lib/models/apis/objects/chat_message.dart index 11f64cd4..d86c3f50 100644 --- a/lib/models/apis/objects/chat_message.dart +++ b/lib/models/apis/objects/chat_message.dart @@ -8,14 +8,16 @@ part 'chat_message.g.dart'; /// 채팅 메시지 모델 (MongoDB) @JsonSerializable() class ChatMessage extends BaseEntity { - final String? chatMessageId; // MongoDB ObjectId - final String? chatRoomId; // UUID - final String? senderId; // UUID - final String? recipientId; // UUID + final String? chatMessageId; + final String? chatRoomId; + final String? senderId; + final String? recipientId; final String? content; final List? imageUrls; final MessageType? type; final bool? isProfanityDetected; + final double? latitude; + final double? longitude; ChatMessage({ super.createdDate, @@ -28,6 +30,8 @@ class ChatMessage extends BaseEntity { this.imageUrls, this.type, this.isProfanityDetected, + this.latitude, + this.longitude, }); factory ChatMessage.fromJson(Map json) => _$ChatMessageFromJson(json); @@ -48,6 +52,8 @@ extension ChatMessageCopy on ChatMessage { bool? isProfanityDetected, DateTime? createdDate, DateTime? updatedDate, + double? latitude, + double? longitude, }) => ChatMessage( chatMessageId: chatMessageId ?? this.chatMessageId, chatRoomId: chatRoomId ?? this.chatRoomId, @@ -59,5 +65,7 @@ extension ChatMessageCopy on ChatMessage { isProfanityDetected: isProfanityDetected ?? this.isProfanityDetected, createdDate: createdDate ?? this.createdDate, updatedDate: updatedDate ?? this.updatedDate, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, ); } diff --git a/lib/models/apis/objects/chat_message.g.dart b/lib/models/apis/objects/chat_message.g.dart index 84420a5f..7a380671 100644 --- a/lib/models/apis/objects/chat_message.g.dart +++ b/lib/models/apis/objects/chat_message.g.dart @@ -17,6 +17,8 @@ ChatMessage _$ChatMessageFromJson(Map json) => ChatMessage( imageUrls: (json['imageUrls'] as List?)?.map((e) => e as String).toList(), type: $enumDecodeNullable(_$MessageTypeEnumMap, json['type']), isProfanityDetected: json['isProfanityDetected'] as bool?, + latitude: (json['latitude'] as num?)?.toDouble(), + longitude: (json['longitude'] as num?)?.toDouble(), ); Map _$ChatMessageToJson(ChatMessage instance) => { @@ -30,6 +32,13 @@ Map _$ChatMessageToJson(ChatMessage instance) => createState() => _ChatLocationPickerScreenState(); +} + +class _ChatLocationPickerScreenState extends State { + final _locationService = LocationService(); + NLatLng? _currentPosition; + NLatLng? _selectedPosition; + LocationAddress? _selectedAddress; + final Completer _mapControllerCompleter = Completer(); + bool _isSending = false; + + @override + void initState() { + super.initState(); + _initializeLocation(); + } + + Future _initializeLocation() async { + final hasPermission = await _locationService.requestPermission(); + if (!hasPermission) { + const seoulCityHall = NLatLng(37.5665, 126.9780); + setState(() => _currentPosition = seoulCityHall); + await _updateAddress(seoulCityHall); + return; + } + final position = await _locationService.getCurrentPosition(); + if (position != null) { + final latLng = _locationService.positionToLatLng(position); + setState(() => _currentPosition = latLng); + await _updateAddress(latLng); + } else { + const seoulCityHall = NLatLng(37.5665, 126.9780); + setState(() => _currentPosition = seoulCityHall); + await _updateAddress(seoulCityHall); + } + } + + Future _updateAddress(NLatLng position) async { + final address = await _locationService.getAddressFromCoordinates(position); + if (address != null && mounted) { + setState(() { + _selectedPosition = position; + _selectedAddress = address; + }); + } + } + + Future _onSend() async { + if (_isSending || _selectedAddress == null || _selectedPosition == null) return; + setState(() => _isSending = true); + final result = LocationAddress( + siDo: _selectedAddress!.siDo, + siGunGu: _selectedAddress!.siGunGu, + eupMyoenDong: _selectedAddress!.eupMyoenDong, + ri: _selectedAddress!.ri, + latitude: _selectedPosition!.latitude, + longitude: _selectedPosition!.longitude, + ); + if (mounted) { + setState(() => _isSending = false); + Navigator.pop(context, result); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CommonAppBar(title: '위치 보내기'), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + children: [ + // 네이버 지도 + Positioned.fill( + child: _currentPosition == null + ? const Center(child: CircularProgressIndicator(color: AppColors.primaryYellow)) + : NaverMap( + options: NaverMapViewOptions( + initialCameraPosition: NCameraPosition(target: _currentPosition!, zoom: 15), + logoAlign: NLogoAlign.leftBottom, + logoMargin: NEdgeInsets.fromEdgeInsets(EdgeInsets.only(left: 24.w, bottom: 137.h)), + indoorEnable: true, + locationButtonEnable: false, + ), + onMapReady: (controller) async { + if (!_mapControllerCompleter.isCompleted) { + _mapControllerCompleter.complete(controller); + controller.setLocationTrackingMode(NLocationTrackingMode.noFollow); + } + }, + onCameraIdle: () async { + final controller = await _mapControllerCompleter.future; + final position = await controller.getCameraPosition(); + await _updateAddress(position.target); + }, + ), + ), + // 중앙 핀 + Center( + child: Container( + margin: EdgeInsets.only(bottom: 40.h), + child: Shadex( + shadowColor: AppColors.opacity20Black, + shadowBlurRadius: 2.0, + shadowOffset: const Offset(2, 2), + child: SvgPicture.asset('assets/images/location-pin.svg'), + ), + ), + ), + // 위치 주소 텍스트 + Positioned( + bottom: isTablet ? 152 : 129.h, + left: 24.w, + right: 24.w, + child: Center( + child: IntrinsicWidth( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), + decoration: BoxDecoration( + color: AppColors.secondaryBlack2, + borderRadius: BorderRadius.circular(100.r), + ), + child: Text( + _selectedAddress != null ? LocationUtils.formatAddress(_selectedAddress!) : '위치 정보 불러오는 중...', + style: CustomTextStyles.p2, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ), + ), + ), + // 보내기 버튼 + Positioned( + left: 24.w, + right: 24.w, + bottom: isTablet ? 80 : 57.h, + child: CompletionButton( + isEnabled: _selectedAddress != null, + isLoading: _isSending, + buttonText: '보내기', + enabledOnPressed: _onSend, + ), + ), + // 현재 위치 버튼 + Positioned( + bottom: isTablet ? 200 : 160.h, + left: 24.w, + child: CurrentLocationButton( + onTap: () async { + final controller = await _mapControllerCompleter.future; + final position = await _locationService.getCurrentPosition(); + if (position != null && mounted) { + final newPosition = _locationService.positionToLatLng(position); + await controller.updateCamera( + NCameraUpdate.fromCameraPosition(NCameraPosition(target: newPosition, zoom: 15)), + ); + controller.setLocationTrackingMode(NLocationTrackingMode.noFollow); + setState(() => _currentPosition = newPosition); + await _updateAddress(newPosition); + } + }, + iconSize: 24.h, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/chat_room_screen.dart b/lib/screens/chat_room_screen.dart index 601ebfae..b9f99dec 100644 --- a/lib/screens/chat_room_screen.dart +++ b/lib/screens/chat_room_screen.dart @@ -8,6 +8,8 @@ import 'package:romrom_fe/enums/snack_bar_type.dart'; import 'package:romrom_fe/enums/trade_status.dart'; import 'package:romrom_fe/icons/app_icons.dart'; import 'package:romrom_fe/models/apis/objects/chat_message.dart'; +import 'package:romrom_fe/models/location_address.dart'; +import 'package:romrom_fe/screens/chat_location_picker_screen.dart'; import 'package:romrom_fe/models/apis/objects/chat_room.dart'; import 'package:romrom_fe/models/apis/objects/chat_user_state.dart'; import 'package:romrom_fe/models/app_colors.dart'; @@ -512,6 +514,27 @@ class _ChatRoomScreenState extends State { } } + Future _onSendLocation() async { + if (_isInputDisabled) return; + FocusScope.of(context).unfocus(); + + final LocationAddress? result = await context.navigateTo(screen: const ChatLocationPickerScreen()); + + if (result == null || !mounted) return; + + final lat = result.latitude; + final lng = result.longitude; + if (lat == null || lng == null) return; + + _wsService.sendMessage( + chatRoomId: widget.chatRoomId, + content: '위치를 보냈습니다.', + type: MessageType.location, + latitude: lat, + longitude: lng, + ); + } + @override void dispose() { _messageSubscription?.cancel(); @@ -633,6 +656,7 @@ class _ChatRoomScreenState extends State { hintText: _inputHintText, onSend: _sendMessage, onPickImage: _onPickImage, + onSendLocation: _onSendLocation, ), ], ), @@ -664,6 +688,7 @@ class _ChatRoomScreenState extends State { !isSameMinute(_messages[index].createdDate, _messages[index - 1].createdDate))); return ChatMessageItem( + key: ValueKey(message.chatMessageId ?? '${message.senderId}_${message.createdDate?.millisecondsSinceEpoch}'), message: message, myMemberId: _myMemberId, topGap: topGap, diff --git a/lib/services/chat_websocket_service.dart b/lib/services/chat_websocket_service.dart index 07432814..1a67a88f 100644 --- a/lib/services/chat_websocket_service.dart +++ b/lib/services/chat_websocket_service.dart @@ -243,10 +243,15 @@ class ChatWebSocketService { required String content, MessageType type = MessageType.text, List? imageUrls, + double? latitude, + double? longitude, }) { if (type == MessageType.image && (imageUrls == null || imageUrls.isEmpty)) { throw Exception('imageUrls is required for image messages'); } + if (type == MessageType.location && (latitude == null || longitude == null)) { + throw Exception('latitude and longitude are required for location messages'); + } if (!_isConnected || _stompClient == null) { debugPrint('[WebSocket] Cannot send message: Not connected'); throw Exception('STOMP not connected'); @@ -258,11 +263,15 @@ class ChatWebSocketService { 'type': type.toString().split('.').last.toUpperCase(), }; - // IMAGE 타입인 경우 imageUrls 추가 if (type == MessageType.image && imageUrls != null) { payload['imageUrls'] = imageUrls; } + if (type == MessageType.location) { + payload['latitude'] = latitude; + payload['longitude'] = longitude; + } + debugPrint('[WebSocket] Sending message to /app/chat.send\n$payload'); _stompClient!.send( destination: '/app/chat.send', diff --git a/lib/widgets/chat_input_bar.dart b/lib/widgets/chat_input_bar.dart index 783f529c..c174a1e5 100644 --- a/lib/widgets/chat_input_bar.dart +++ b/lib/widgets/chat_input_bar.dart @@ -17,6 +17,7 @@ class ChatInputBar extends StatelessWidget { final String hintText; final VoidCallback onSend; final VoidCallback onPickImage; + final VoidCallback onSendLocation; const ChatInputBar({ super.key, @@ -28,6 +29,7 @@ class ChatInputBar extends StatelessWidget { required this.hintText, required this.onSend, required this.onPickImage, + required this.onSendLocation, }); @override @@ -64,6 +66,13 @@ class ChatInputBar extends StatelessWidget { title: '사진 선택하기', onTap: () => onPickImage(), ), + ContextMenuItem( + id: 'send_location', + icon: AppIcons.location, + iconColor: AppColors.opacity60White, + title: '위치 보내기', + onTap: () => onSendLocation(), + ), ], ), ), diff --git a/lib/widgets/chat_location_bubble.dart b/lib/widgets/chat_location_bubble.dart new file mode 100644 index 00000000..1ef70ddc --- /dev/null +++ b/lib/widgets/chat_location_bubble.dart @@ -0,0 +1,174 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_naver_map/flutter_naver_map.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:http/http.dart' as http; +import 'package:romrom_fe/models/apis/objects/chat_message.dart'; +import 'package:romrom_fe/models/app_colors.dart'; +import 'package:romrom_fe/models/app_theme.dart'; +import 'package:romrom_fe/models/app_urls.dart'; +import 'package:romrom_fe/models/location_address.dart'; +import 'package:romrom_fe/services/location_service.dart'; +import 'package:romrom_fe/utils/location_utils.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// LOCATION 타입 메시지 말풍선 +/// - Static Maps API 이미지 (220w × 130h) — http.get으로 직접 요청(헤더 보장) +/// - 주소 텍스트 (lat/lng → 역지오코딩) +/// - "지도에서 보기" 버튼 → 네이버지도 앱 or 웹 폴백 +class ChatLocationBubble extends StatefulWidget { + final ChatMessage message; + + const ChatLocationBubble({super.key, required this.message}); + + @override + State createState() => _ChatLocationBubbleState(); +} + +class _ChatLocationBubbleState extends State { + String _address = ''; + Uint8List? _mapImageBytes; + bool _mapImageError = false; + + @override + void initState() { + super.initState(); + _fetchAddress(); + _fetchMapImage(); + } + + Future _fetchAddress() async { + final lat = widget.message.latitude; + final lng = widget.message.longitude; + if (lat == null || lng == null) return; + + final LocationAddress? result = await LocationService().getAddressFromCoordinates(NLatLng(lat, lng)); + if (!mounted) return; + + if (result != null) { + setState(() => _address = LocationUtils.formatAddress(result)); + } + } + + Future _fetchMapImage() async { + final lat = widget.message.latitude; + final lng = widget.message.longitude; + if (lat == null || lng == null) return; + + try { + final uri = Uri.parse(AppUrls.naverStaticMapApiUrl).replace( + queryParameters: { + 'w': '528', + 'h': '352', + 'center': '$lng,$lat', + 'level': '15', + 'markers': 'type:d|size:mid|color:0xFFC300|pos:$lng $lat', + }, + ); + + final response = await http.get( + uri, + headers: { + 'x-ncp-apigw-api-key-id': dotenv.get('NMF_CLIENT_ID'), + 'x-ncp-apigw-api-key': dotenv.get('NMF_CLIENT_SECRET'), + }, + ); + + if (!mounted) return; + + if (response.statusCode == 200) { + setState(() => _mapImageBytes = response.bodyBytes); + } else { + setState(() => _mapImageError = true); + } + } catch (_) { + if (mounted) setState(() => _mapImageError = true); + } + } + + Future _openNaverMap() async { + final lat = widget.message.latitude; + final lng = widget.message.longitude; + if (lat == null || lng == null) return; + + final appUri = Uri.parse('nmap://map?lat=$lat&lng=$lng&zoom=15&appname=com.alom.romrom'); + final webUri = Uri.parse('https://map.naver.com/v5/?c=$lng,$lat,15,0,0,0,dh'); + + if (await canLaunchUrl(appUri)) { + await launchUrl(appUri); + } else { + await launchUrl(webUri, mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context) { + final double bubbleWidth = 264.w; + return ClipRRect( + borderRadius: BorderRadius.circular(10.r), + child: Container( + width: bubbleWidth, + decoration: BoxDecoration(color: AppColors.secondaryBlack1, borderRadius: BorderRadius.circular(10.r)), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 지도 이미지 + SizedBox(width: bubbleWidth, height: 176.h, child: _buildMapImage(bubbleWidth)), + // 주소 텍스트 + Padding( + padding: EdgeInsets.only(left: 12.w, top: 9.h, bottom: 1.h), + child: Text( + _address.isNotEmpty ? _address : '위치', + style: CustomTextStyles.p3.copyWith(height: 1.2), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + // 지도에서 보기 버튼 + Padding( + padding: EdgeInsets.all(8.0.w), + child: GestureDetector( + onTap: _openNaverMap, + behavior: HitTestBehavior.opaque, + child: Container( + decoration: BoxDecoration(color: AppColors.secondaryBlack2, borderRadius: BorderRadius.circular(4.r)), + padding: EdgeInsets.symmetric(vertical: 12.h), + alignment: Alignment.center, + child: Text( + '지도에서 보기', + style: CustomTextStyles.p3.copyWith(color: AppColors.textColorWhite, fontWeight: FontWeight.w500), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildMapImage(double width) { + if (_mapImageError || (widget.message.latitude == null || widget.message.longitude == null)) { + return Container( + color: AppColors.secondaryBlack2, + child: const Center(child: Icon(Icons.map_outlined, color: AppColors.opacity50White, size: 32)), + ); + } + if (_mapImageBytes == null) { + return Container( + color: AppColors.secondaryBlack2, + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primaryYellow), + ), + ), + ); + } + return Image.memory(_mapImageBytes!, width: width, height: 176.h, fit: BoxFit.cover); + } +} diff --git a/lib/widgets/chat_message_item.dart b/lib/widgets/chat_message_item.dart index 33bbbb27..998fdfd5 100644 --- a/lib/widgets/chat_message_item.dart +++ b/lib/widgets/chat_message_item.dart @@ -7,6 +7,7 @@ import 'package:romrom_fe/models/app_colors.dart'; import 'package:romrom_fe/models/app_theme.dart'; import 'package:romrom_fe/utils/common_utils.dart'; import 'package:romrom_fe/widgets/chat_image_bubble.dart'; +import 'package:romrom_fe/widgets/chat_location_bubble.dart'; /// 채팅 메시지 아이템 위젯 /// system / text / image (업로드 중 포함) 모든 메시지 타입을 처리 @@ -87,6 +88,9 @@ class ChatMessageItem extends StatelessWidget { } Widget _buildBubble(BuildContext context, {required bool isMine}) { + if (message.type == MessageType.location) { + return ChatLocationBubble(message: message); + } if (message.type == MessageType.image) { return isUploading ? _buildUploadingBubble() : chatImageBubble(context, message); }