diff --git a/.gitignore b/.gitignore index 16c7970..6ca755f 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ app.*.map.json # ==================================================================== /.claude/settings.local.json /.report +/.issue CLAUDE.md .env # Play Store CI/CD - 민감한 파일 (자동 생성됨) diff --git a/CHANGELOG.json b/CHANGELOG.json index f23f0a2..9361e08 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,33 @@ { "metadata": { - "lastUpdated": "2026-02-23T02:13:37Z", - "currentVersion": "1.0.41", + "lastUpdated": "2026-02-23T04:16:06Z", + "currentVersion": "1.0.42", "projectType": "flutter", - "totalReleases": 12 + "totalReleases": 13 }, "releases": [ + { + "version": "1.0.42", + "project_type": "flutter", + "date": "2026-02-23", + "pr_number": 40, + "raw_summary": "## Summary by CodeRabbit\n\n## 릴리스 노트\n\n* **새로운 기능**\n * AI 장소 추출 기능 추가: SNS 링크에서 위치 정보를 자동으로 추출하고, 추출된 장소를 선택하여 저장할 수 있습니다.\n\n* **버전 업데이트**\n * 버전 1.0.42로 업데이트되었습니다.", + "parsed_changes": { + "새로운_기능": { + "title": "새로운 기능", + "items": [ + "AI 장소 추출 기능 추가: SNS 링크에서 위치 정보를 자동으로 추출하고, 추출된 장소를 선택하여 저장할 수 있습니다." + ] + }, + "버전_업데이트": { + "title": "버전 업데이트", + "items": [ + "버전 1.0.42로 업데이트되었습니다." + ] + } + }, + "parse_method": "markdown" + }, { "version": "1.0.41", "project_type": "flutter", diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae14ed..db3d159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,19 @@ # Changelog -**현재 버전:** 1.0.41 -**마지막 업데이트:** 2026-02-23T02:13:37Z +**현재 버전:** 1.0.42 +**마지막 업데이트:** 2026-02-23T04:16:06Z + +--- + +## [1.0.42] - 2026-02-23 + +**PR:** #40 + +**새로운 기능** +- AI 장소 추출 기능 추가: SNS 링크에서 위치 정보를 자동으로 추출하고, 추출된 장소를 선택하여 저장할 수 있습니다. + +**버전 업데이트** +- 버전 1.0.42로 업데이트되었습니다. --- diff --git a/README.md b/README.md index c8cab51..324c6b4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,6 @@ samples, guidance on mobile development, and a full API reference. --- -## 최신 버전 : v1.0.40 (2026-02-23) +## 최신 버전 : v1.0.41 (2026-02-23) [전체 버전 기록 보기](CHANGELOG.md) diff --git a/lib/features/home/data/models/place_model.dart b/lib/common/models/place_model.dart similarity index 96% rename from lib/features/home/data/models/place_model.dart rename to lib/common/models/place_model.dart index ff2618c..477ca87 100644 --- a/lib/features/home/data/models/place_model.dart +++ b/lib/common/models/place_model.dart @@ -3,7 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'place_model.freezed.dart'; part 'place_model.g.dart'; -/// 장소 모델 +/// 장소 모델 (공통) @freezed class PlaceModel with _$PlaceModel { const factory PlaceModel({ diff --git a/lib/common/widgets/main_scaffold.dart b/lib/common/widgets/main_scaffold.dart index edbdfb2..4679781 100644 --- a/lib/common/widgets/main_scaffold.dart +++ b/lib/common/widgets/main_scaffold.dart @@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import '../constants/home_colors.dart'; +import '../../routing/route_paths.dart'; /// 메인 네비게이션 셸 (하단 네비게이션 바 + FAB) /// @@ -25,12 +26,7 @@ class MainScaffold extends StatelessWidget { height: 56.w, child: FloatingActionButton( onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('AI 장소 추출 기능 준비 중'), - duration: Duration(seconds: 2), - ), - ); + context.push(RoutePaths.aiExtraction); }, elevation: 2, backgroundColor: HomeColors.textPrimary, diff --git a/lib/features/ai_extraction/data/ai_extraction_remote_datasource.dart b/lib/features/ai_extraction/data/ai_extraction_remote_datasource.dart new file mode 100644 index 0000000..77fd887 --- /dev/null +++ b/lib/features/ai_extraction/data/ai_extraction_remote_datasource.dart @@ -0,0 +1,70 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../common/constants/api_endpoints.dart'; +import '../../../common/services/api_client.dart'; +import 'models/analyze_request.dart'; +import 'models/analyze_response.dart'; +import 'models/content_detail_response.dart'; + +part 'ai_extraction_remote_datasource.g.dart'; + +@riverpod +AiExtractionRemoteDataSource aiExtractionRemoteDataSource(Ref ref) { + final dio = ref.watch(dioProvider); + return AiExtractionRemoteDataSource(dio); +} + +/// AI 장소 추출 Remote DataSource +class AiExtractionRemoteDataSource { + final Dio _dio; + + AiExtractionRemoteDataSource(this._dio); + + /// AI 분석 요청 + /// POST /api/content/analyze + Future analyze(AnalyzeRequest request) async { + debugPrint('📤 AiExtraction: Analyzing URL: ${request.sourceUrl}'); + + final response = await _dio.post( + ApiEndpoints.contentAnalyze, + data: request.toJson(), + ); + + final result = AnalyzeResponse.fromJson( + response.data as Map, + ); + debugPrint('✅ Analyze requested: contentId=${result.contentId}'); + return result; + } + + /// 콘텐츠 상세 조회 (폴링용) + /// GET /api/content/{contentId} + Future getContentDetail(int contentId) async { + debugPrint('📤 AiExtraction: Polling contentId=$contentId'); + + final response = await _dio.get( + ApiEndpoints.contentDetail(contentId.toString()), + ); + + final result = ContentDetailResponse.fromJson( + response.data as Map, + ); + debugPrint('✅ Content status: ${result.status}'); + return result; + } + + /// 장소 저장 + /// POST /api/place/{placeId}/save + Future savePlace(int placeId) async { + debugPrint('📤 AiExtraction: Saving placeId=$placeId'); + + await _dio.post( + ApiEndpoints.savePlace(placeId.toString()), + ); + + debugPrint('✅ Place saved: placeId=$placeId'); + } +} diff --git a/lib/features/ai_extraction/data/ai_extraction_repository.dart b/lib/features/ai_extraction/data/ai_extraction_repository.dart new file mode 100644 index 0000000..13dd8e1 --- /dev/null +++ b/lib/features/ai_extraction/data/ai_extraction_repository.dart @@ -0,0 +1,15 @@ +import 'models/analyze_request.dart'; +import 'models/analyze_response.dart'; +import 'models/content_detail_response.dart'; + +/// AI 장소 추출 Repository 인터페이스 +abstract class AiExtractionRepository { + /// AI 분석 요청 + Future analyze(AnalyzeRequest request); + + /// 콘텐츠 상세 조회 (폴링용) + Future getContentDetail(int contentId); + + /// 장소 저장 + Future savePlace(int placeId); +} diff --git a/lib/features/ai_extraction/data/ai_extraction_repository_impl.dart b/lib/features/ai_extraction/data/ai_extraction_repository_impl.dart new file mode 100644 index 0000000..0ad9c16 --- /dev/null +++ b/lib/features/ai_extraction/data/ai_extraction_repository_impl.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'ai_extraction_repository.dart'; +import 'ai_extraction_remote_datasource.dart'; +import 'models/analyze_request.dart'; +import 'models/analyze_response.dart'; +import 'models/content_detail_response.dart'; + +part 'ai_extraction_repository_impl.g.dart'; + +@riverpod +AiExtractionRepository aiExtractionRepository(Ref ref) { + final remoteDataSource = ref.watch(aiExtractionRemoteDataSourceProvider); + return AiExtractionRepositoryImpl(remoteDataSource); +} + +/// AI 장소 추출 Repository 구현체 +class AiExtractionRepositoryImpl implements AiExtractionRepository { + final AiExtractionRemoteDataSource _remoteDataSource; + + AiExtractionRepositoryImpl(this._remoteDataSource); + + @override + Future analyze(AnalyzeRequest request) async { + debugPrint('📝 AiExtractionRepo: Analyzing...'); + return await _remoteDataSource.analyze(request); + } + + @override + Future getContentDetail(int contentId) async { + debugPrint('📝 AiExtractionRepo: Getting content detail...'); + return await _remoteDataSource.getContentDetail(contentId); + } + + @override + Future savePlace(int placeId) async { + debugPrint('📝 AiExtractionRepo: Saving place...'); + return await _remoteDataSource.savePlace(placeId); + } +} diff --git a/lib/features/ai_extraction/data/models/analyze_request.dart b/lib/features/ai_extraction/data/models/analyze_request.dart new file mode 100644 index 0000000..5b9632f --- /dev/null +++ b/lib/features/ai_extraction/data/models/analyze_request.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'analyze_request.freezed.dart'; +part 'analyze_request.g.dart'; + +/// AI 분석 요청 DTO +@freezed +class AnalyzeRequest with _$AnalyzeRequest { + const factory AnalyzeRequest({ + /// SNS URL (Instagram, YouTube) + required String sourceUrl, + }) = _AnalyzeRequest; + + factory AnalyzeRequest.fromJson(Map json) => + _$AnalyzeRequestFromJson(json); +} diff --git a/lib/features/ai_extraction/data/models/analyze_response.dart b/lib/features/ai_extraction/data/models/analyze_response.dart new file mode 100644 index 0000000..ad3c9ae --- /dev/null +++ b/lib/features/ai_extraction/data/models/analyze_response.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'analyze_response.freezed.dart'; +part 'analyze_response.g.dart'; + +/// AI 분석 응답 DTO +@freezed +class AnalyzeResponse with _$AnalyzeResponse { + const factory AnalyzeResponse({ + /// 폴링용 콘텐츠 ID + required int contentId, + }) = _AnalyzeResponse; + + factory AnalyzeResponse.fromJson(Map json) => + _$AnalyzeResponseFromJson(json); +} diff --git a/lib/features/ai_extraction/data/models/content_detail_response.dart b/lib/features/ai_extraction/data/models/content_detail_response.dart new file mode 100644 index 0000000..ad2fbb0 --- /dev/null +++ b/lib/features/ai_extraction/data/models/content_detail_response.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../common/models/place_model.dart'; + +part 'content_detail_response.freezed.dart'; +part 'content_detail_response.g.dart'; + +/// 콘텐츠 상세 응답 DTO (폴링용) +@freezed +class ContentDetailResponse with _$ContentDetailResponse { + const factory ContentDetailResponse({ + required int contentId, + /// PENDING, PROCESSING, COMPLETED, FAILED + required String status, + String? sourceUrl, + @Default([]) List places, + }) = _ContentDetailResponse; + + factory ContentDetailResponse.fromJson(Map json) => + _$ContentDetailResponseFromJson(json); +} diff --git a/lib/features/ai_extraction/presentation/ai_extraction_provider.dart b/lib/features/ai_extraction/presentation/ai_extraction_provider.dart new file mode 100644 index 0000000..db827e3 --- /dev/null +++ b/lib/features/ai_extraction/presentation/ai_extraction_provider.dart @@ -0,0 +1,249 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../common/models/place_model.dart'; +import '../data/ai_extraction_repository_impl.dart'; +import '../data/models/analyze_request.dart'; +import '../data/models/content_detail_response.dart'; + +part 'ai_extraction_provider.freezed.dart'; +part 'ai_extraction_provider.g.dart'; + +/// AI 추출 화면 단계 +enum AiExtractionStep { + input, + analyzing, + completed, + saving, + saved, + error, +} + +/// AI 추출 화면 상태 +@freezed +class AiExtractionState with _$AiExtractionState { + const factory AiExtractionState({ + @Default(AiExtractionStep.input) AiExtractionStep step, + @Default('') String url, + int? contentId, + @Default([]) List places, + @Default({}) Set selectedPlaceIds, + String? errorMessage, + @Default(0.0) double saveProgress, + }) = _AiExtractionState; +} + +/// AI 추출 화면 Notifier +@riverpod +class AiExtractionNotifier extends _$AiExtractionNotifier { + Timer? _pollingTimer; + bool _disposed = false; + + @override + AiExtractionState build() { + _disposed = false; + ref.onDispose(() { + _disposed = true; + _pollingTimer?.cancel(); + }); + return const AiExtractionState(); + } + + void updateUrl(String url) { + state = state.copyWith(url: url); + } + + /// URL 유효성 검증 + bool _isValidUrl(String url) { + final uri = Uri.tryParse(url); + return uri != null && (uri.scheme == 'http' || uri.scheme == 'https'); + } + + Future analyze() async { + final trimmedUrl = state.url.trim(); + if (trimmedUrl.isEmpty) return; + + if (!_isValidUrl(trimmedUrl)) { + state = state.copyWith( + step: AiExtractionStep.error, + errorMessage: '올바른 URL을 입력해주세요.', + ); + return; + } + + state = state.copyWith( + step: AiExtractionStep.analyzing, + errorMessage: null, + ); + + try { + final repository = ref.read(aiExtractionRepositoryProvider); + final response = await repository.analyze( + AnalyzeRequest(sourceUrl: trimmedUrl), + ); + + if (_disposed) return; + state = state.copyWith(contentId: response.contentId); + _startPolling(response.contentId); + } catch (e) { + debugPrint('❌ AiExtraction: Analyze failed: $e'); + if (_disposed) return; + state = state.copyWith( + step: AiExtractionStep.error, + errorMessage: 'URL 분석에 실패했습니다. 다시 시도해주세요.', + ); + } + } + + void _startPolling(int contentId) { + int attempts = 0; + int consecutiveFailures = 0; + const maxAttempts = 60; + const maxConsecutiveFailures = 3; + + _pollingTimer?.cancel(); + _pollingTimer = Timer.periodic( + const Duration(seconds: 5), + (timer) async { + if (_disposed) { + timer.cancel(); + return; + } + + attempts++; + + if (attempts > maxAttempts) { + timer.cancel(); + if (_disposed) return; + state = state.copyWith( + step: AiExtractionStep.error, + errorMessage: '분석 시간이 초과되었습니다. 다시 시도해주세요.', + ); + return; + } + + try { + final repository = ref.read(aiExtractionRepositoryProvider); + final detail = await repository.getContentDetail(contentId); + + if (_disposed) { + timer.cancel(); + return; + } + + consecutiveFailures = 0; + + if (detail.status.toUpperCase() == 'COMPLETED') { + timer.cancel(); + final allPlaceIds = detail.places.map((p) => p.placeId).toSet(); + state = state.copyWith( + step: AiExtractionStep.completed, + places: detail.places, + selectedPlaceIds: allPlaceIds, + ); + } else if (detail.status.toUpperCase() == 'FAILED') { + timer.cancel(); + state = state.copyWith( + step: AiExtractionStep.error, + errorMessage: '장소 추출에 실패했습니다. 다시 시도해주세요.', + ); + } + } catch (e) { + debugPrint('❌ AiExtraction: Polling failed: $e'); + consecutiveFailures++; + if (consecutiveFailures >= maxConsecutiveFailures) { + timer.cancel(); + if (_disposed) return; + state = state.copyWith( + step: AiExtractionStep.error, + errorMessage: '네트워크 오류가 발생했습니다. 다시 시도해주세요.', + ); + } + } + }, + ); + } + + void togglePlace(int placeId) { + final selected = Set.from(state.selectedPlaceIds); + if (selected.contains(placeId)) { + selected.remove(placeId); + } else { + selected.add(placeId); + } + state = state.copyWith(selectedPlaceIds: selected); + } + + void toggleAll() { + if (state.selectedPlaceIds.length == state.places.length) { + state = state.copyWith(selectedPlaceIds: {}); + } else { + final allIds = state.places.map((p) => p.placeId).toSet(); + state = state.copyWith(selectedPlaceIds: allIds); + } + } + + Future saveSelectedPlaces() async { + if (state.selectedPlaceIds.isEmpty) return; + + state = state.copyWith( + step: AiExtractionStep.saving, + saveProgress: 0.0, + ); + + try { + final repository = ref.read(aiExtractionRepositoryProvider); + final placeIds = state.selectedPlaceIds.toList(); + final failedIds = []; + + for (int i = 0; i < placeIds.length; i++) { + try { + await repository.savePlace(placeIds[i]); + } catch (e) { + debugPrint('❌ AiExtraction: Failed to save placeId=${placeIds[i]}: $e'); + failedIds.add(placeIds[i]); + } + if (_disposed) return; + state = state.copyWith( + saveProgress: (i + 1) / placeIds.length, + ); + } + + if (_disposed) return; + + if (failedIds.isEmpty) { + state = state.copyWith(step: AiExtractionStep.saved); + } else { + state = state.copyWith( + step: AiExtractionStep.error, + errorMessage: '${failedIds.length}개의 장소 저장에 실패했습니다.', + selectedPlaceIds: failedIds.toSet(), + ); + } + } catch (e) { + debugPrint('❌ AiExtraction: Save failed: $e'); + if (_disposed) return; + state = state.copyWith( + step: AiExtractionStep.error, + errorMessage: '장소 저장에 실패했습니다. 다시 시도해주세요.', + ); + } + } + + void retry() { + _pollingTimer?.cancel(); + state = const AiExtractionState(); + } + + void cancel() { + _pollingTimer?.cancel(); + if (_disposed) return; + state = state.copyWith( + step: AiExtractionStep.input, + errorMessage: null, + ); + } +} diff --git a/lib/features/ai_extraction/presentation/pages/ai_extraction_page.dart b/lib/features/ai_extraction/presentation/pages/ai_extraction_page.dart new file mode 100644 index 0000000..0339335 --- /dev/null +++ b/lib/features/ai_extraction/presentation/pages/ai_extraction_page.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../common/constants/home_colors.dart'; +import '../../../../common/constants/spacing_and_radius.dart'; +import '../../../../common/constants/text_styles.dart'; +import '../ai_extraction_provider.dart'; +import '../widgets/url_input_section.dart'; +import '../widgets/analyzing_section.dart'; +import '../widgets/place_result_section.dart'; + +/// AI 장소 추출 페이지 (단일 페이지, 상태 전환) +class AiExtractionPage extends ConsumerStatefulWidget { + const AiExtractionPage({super.key}); + + @override + ConsumerState createState() => _AiExtractionPageState(); +} + +class _AiExtractionPageState extends ConsumerState { + final _urlController = TextEditingController(); + + @override + void initState() { + super.initState(); + _urlController.addListener(() { + ref.read(aiExtractionNotifierProvider.notifier).updateUrl( + _urlController.text, + ); + }); + } + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(aiExtractionNotifierProvider); + final notifier = ref.read(aiExtractionNotifierProvider.notifier); + + // 저장 완료 시 스낵바 + 홈으로 이동 + ref.listen(aiExtractionNotifierProvider, (prev, next) { + if (prev?.step != AiExtractionStep.saved && + next.step == AiExtractionStep.saved) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${next.selectedPlaceIds.length}개의 장소가 저장되었습니다', + ), + duration: const Duration(seconds: 2), + ), + ); + Future.delayed(const Duration(seconds: 1), () { + if (mounted) context.pop(); + }); + } + }); + + return PopScope( + canPop: state.step == AiExtractionStep.input || + state.step == AiExtractionStep.completed || + state.step == AiExtractionStep.error || + state.step == AiExtractionStep.saved, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + if (state.step == AiExtractionStep.analyzing) { + notifier.cancel(); + } else if (state.step == AiExtractionStep.saving) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('저장이 진행 중입니다. 잠시만 기다려주세요.'), + duration: Duration(seconds: 1), + ), + ); + } + } + }, + child: Scaffold( + backgroundColor: HomeColors.background, + appBar: AppBar( + backgroundColor: HomeColors.background, + elevation: 0, + leading: IconButton( + onPressed: () { + if (state.step == AiExtractionStep.analyzing) { + notifier.cancel(); + } + Navigator.maybePop(context); + }, + icon: Icon(Icons.arrow_back, color: HomeColors.textPrimary), + ), + title: Text( + 'AI 장소 추출', + style: AppTextStyles.subHeading.copyWith(color: HomeColors.textPrimary), + ), + centerTitle: true, + ), + body: _buildBody(state, notifier), + ), + ); + } + + Widget _buildBody(AiExtractionState state, AiExtractionNotifier notifier) { + switch (state.step) { + case AiExtractionStep.input: + return UrlInputSection( + controller: _urlController, + onAnalyze: () => notifier.analyze(), + isValid: state.url.trim().isNotEmpty, + ); + + case AiExtractionStep.analyzing: + return AnalyzingSection( + onCancel: () => notifier.cancel(), + ); + + case AiExtractionStep.completed: + case AiExtractionStep.saving: + return PlaceResultSection( + places: state.places, + selectedPlaceIds: state.selectedPlaceIds, + onTogglePlace: (id) => notifier.togglePlace(id), + onToggleAll: () => notifier.toggleAll(), + onSave: () => notifier.saveSelectedPlaces(), + isSaving: state.step == AiExtractionStep.saving, + saveProgress: state.saveProgress, + ); + + case AiExtractionStep.saved: + return const Center( + child: CircularProgressIndicator(), + ); + + case AiExtractionStep.error: + return _buildErrorSection(state, notifier); + } + } + + Widget _buildErrorSection( + AiExtractionState state, + AiExtractionNotifier notifier, + ) { + return Center( + child: Padding( + padding: AppPadding.all20, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64.sp, + color: HomeColors.textDisabled, + ), + SizedBox(height: 16.h), + Text( + state.errorMessage ?? '오류가 발생했습니다', + style: AppTextStyles.label.copyWith( + color: HomeColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 24.h), + ElevatedButton( + onPressed: () => notifier.retry(), + style: ElevatedButton.styleFrom( + backgroundColor: HomeColors.textPrimary, + foregroundColor: HomeColors.background, + ), + child: Text('다시 시도', style: AppTextStyles.paragraph), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/ai_extraction/presentation/widgets/analyzing_section.dart b/lib/features/ai_extraction/presentation/widgets/analyzing_section.dart new file mode 100644 index 0000000..fce9b43 --- /dev/null +++ b/lib/features/ai_extraction/presentation/widgets/analyzing_section.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; +import '../../../../common/constants/text_styles.dart'; + +/// AI 분석 중 위젯 (로딩 애니메이션) +class AnalyzingSection extends StatelessWidget { + const AnalyzingSection({ + super.key, + required this.onCancel, + }); + + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 64.w, + height: 64.w, + child: CircularProgressIndicator( + strokeWidth: 3.w, + color: HomeColors.textPrimary, + ), + ), + SizedBox(height: 24.h), + Text( + 'AI가 장소를 추출하고 있어요', + style: AppTextStyles.subHeading, + ), + SizedBox(height: 8.h), + Text( + '잠시만 기다려주세요...', + style: AppTextStyles.paragraph.copyWith( + color: HomeColors.textSecondary, + ), + ), + SizedBox(height: 32.h), + TextButton( + onPressed: onCancel, + child: Text( + '취소', + style: AppTextStyles.paragraph.copyWith( + color: HomeColors.textSecondary, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/ai_extraction/presentation/widgets/place_result_section.dart b/lib/features/ai_extraction/presentation/widgets/place_result_section.dart new file mode 100644 index 0000000..2ec8ec1 --- /dev/null +++ b/lib/features/ai_extraction/presentation/widgets/place_result_section.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; +import '../../../../common/constants/text_styles.dart'; +import '../../../../common/constants/spacing_and_radius.dart'; +import '../../../../common/models/place_model.dart'; + +/// 장소 결과 + 선택 + 저장 위젯 +class PlaceResultSection extends StatelessWidget { + const PlaceResultSection({ + super.key, + required this.places, + required this.selectedPlaceIds, + required this.onTogglePlace, + required this.onToggleAll, + required this.onSave, + required this.isSaving, + required this.saveProgress, + }); + + final List places; + final Set selectedPlaceIds; + final ValueChanged onTogglePlace; + final VoidCallback onToggleAll; + final VoidCallback onSave; + final bool isSaving; + final double saveProgress; + + @override + Widget build(BuildContext context) { + final isAllSelected = selectedPlaceIds.length == places.length; + + return Column( + children: [ + // 헤더: 추출 결과 + 전체선택 + Padding( + padding: AppPadding.horizontal20, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 20.h), + Text( + '${places.length}개의 장소를 찾았어요', + style: AppTextStyles.subHeading, + ), + SizedBox(height: 4.h), + Text( + '저장할 장소를 선택하세요', + style: AppTextStyles.paragraph.copyWith( + color: HomeColors.textSecondary, + ), + ), + SizedBox(height: 12.h), + GestureDetector( + onTap: onToggleAll, + child: Row( + children: [ + Icon( + isAllSelected + ? Icons.check_circle + : Icons.circle_outlined, + color: isAllSelected + ? HomeColors.textPrimary + : HomeColors.textDisabled, + size: 22.sp, + ), + SizedBox(width: 8.w), + Text( + isAllSelected ? '전체 해제' : '전체 선택', + style: AppTextStyles.paragraph.copyWith( + color: HomeColors.textSecondary, + ), + ), + ], + ), + ), + SizedBox(height: 8.h), + Divider(color: HomeColors.divider, height: 1), + ], + ), + ), + + // 장소 리스트 + Expanded( + child: ListView.separated( + padding: EdgeInsets.symmetric(vertical: 8.h), + itemCount: places.length, + separatorBuilder: (_, __) => Divider( + color: HomeColors.divider, + height: 1, + indent: 20.w, + endIndent: 20.w, + ), + itemBuilder: (context, index) { + final place = places[index]; + final isSelected = selectedPlaceIds.contains(place.placeId); + + return _PlaceSelectItem( + place: place, + isSelected: isSelected, + onTap: () => onTogglePlace(place.placeId), + ); + }, + ), + ), + + // 하단 저장 버튼 + Container( + padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 12.h), + decoration: BoxDecoration( + color: HomeColors.background, + border: Border( + top: BorderSide(color: HomeColors.divider, width: 1), + ), + ), + child: SafeArea( + child: SizedBox( + width: double.infinity, + height: 52.h, + child: ElevatedButton( + onPressed: selectedPlaceIds.isNotEmpty && !isSaving + ? onSave + : null, + style: ElevatedButton.styleFrom( + backgroundColor: HomeColors.textPrimary, + disabledBackgroundColor: HomeColors.divider, + foregroundColor: HomeColors.background, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.large, + ), + ), + child: isSaving + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20.w, + height: 20.w, + child: CircularProgressIndicator( + strokeWidth: 2.w, + color: HomeColors.background, + value: saveProgress > 0 ? saveProgress : null, + ), + ), + SizedBox(width: 8.w), + Text( + '저장 중...', + style: AppTextStyles.label.copyWith( + color: HomeColors.background, + ), + ), + ], + ) + : Text( + '${selectedPlaceIds.length}개 장소 저장하기', + style: AppTextStyles.label.copyWith( + color: selectedPlaceIds.isNotEmpty + ? HomeColors.background + : HomeColors.textDisabled, + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +/// 장소 선택 아이템 +class _PlaceSelectItem extends StatelessWidget { + const _PlaceSelectItem({ + required this.place, + required this.isSelected, + required this.onTap, + }); + + final PlaceModel place; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 14.h), + child: Row( + children: [ + Icon( + isSelected ? Icons.check_circle : Icons.circle_outlined, + color: isSelected + ? HomeColors.textPrimary + : HomeColors.textDisabled, + size: 24.sp, + ), + SizedBox(width: 12.w), + ClipRRect( + borderRadius: AppRadius.medium, + child: Container( + width: 56.w, + height: 56.w, + color: HomeColors.shimmerBase, + child: place.imageUrl != null + ? Image.network( + place.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Icons.place, + color: HomeColors.textDisabled, + size: 24.sp, + ), + ) + : Icon( + Icons.place, + color: HomeColors.textDisabled, + size: 24.sp, + ), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + place.placeName, + style: AppTextStyles.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (place.address != null) ...[ + SizedBox(height: 2.h), + Text( + place.address!, + style: AppTextStyles.callout.copyWith( + color: HomeColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (place.category != null) ...[ + SizedBox(height: 2.h), + Text( + place.category!, + style: AppTextStyles.callout.copyWith( + color: HomeColors.textDisabled, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/ai_extraction/presentation/widgets/url_input_section.dart b/lib/features/ai_extraction/presentation/widgets/url_input_section.dart new file mode 100644 index 0000000..05bc178 --- /dev/null +++ b/lib/features/ai_extraction/presentation/widgets/url_input_section.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../common/constants/home_colors.dart'; +import '../../../../common/constants/text_styles.dart'; +import '../../../../common/constants/spacing_and_radius.dart'; + +/// URL 입력 섹션 위젯 +class UrlInputSection extends StatelessWidget { + const UrlInputSection({ + super.key, + required this.controller, + required this.onAnalyze, + required this.isValid, + }); + + final TextEditingController controller; + final VoidCallback onAnalyze; + final bool isValid; + + @override + Widget build(BuildContext context) { + return Padding( + padding: AppPadding.horizontal20, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 40.h), + Text( + 'SNS 링크를 붙여넣어\n장소를 추출해보세요', + style: AppTextStyles.heading02, + ), + SizedBox(height: 8.h), + Text( + 'Instagram, YouTube 링크를 지원합니다', + style: AppTextStyles.paragraph.copyWith( + color: HomeColors.textSecondary, + ), + ), + SizedBox(height: 32.h), + TextField( + controller: controller, + decoration: InputDecoration( + hintText: 'https://www.instagram.com/p/...', + hintStyle: AppTextStyles.label.copyWith( + color: HomeColors.textDisabled, + ), + border: OutlineInputBorder( + borderRadius: AppRadius.large, + borderSide: BorderSide(color: HomeColors.divider), + ), + enabledBorder: OutlineInputBorder( + borderRadius: AppRadius.large, + borderSide: BorderSide(color: HomeColors.divider), + ), + focusedBorder: OutlineInputBorder( + borderRadius: AppRadius.large, + borderSide: BorderSide(color: HomeColors.textPrimary, width: 1.5), + ), + contentPadding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 14.h, + ), + suffixIcon: IconButton( + onPressed: () async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null) { + controller.text = data!.text!; + } + }, + icon: Icon( + Icons.content_paste, + color: HomeColors.textSecondary, + size: 20.sp, + ), + ), + ), + style: AppTextStyles.label, + keyboardType: TextInputType.url, + ), + SizedBox(height: 24.h), + SizedBox( + width: double.infinity, + height: 52.h, + child: ElevatedButton( + onPressed: isValid ? onAnalyze : null, + style: ElevatedButton.styleFrom( + backgroundColor: HomeColors.textPrimary, + disabledBackgroundColor: HomeColors.divider, + foregroundColor: HomeColors.background, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.large, + ), + ), + child: Text( + '장소 추출하기', + style: AppTextStyles.label.copyWith( + color: isValid ? HomeColors.background : HomeColors.textDisabled, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/data/models/content_response.dart b/lib/features/home/data/models/content_response.dart index 4bd13b8..1e58e95 100644 --- a/lib/features/home/data/models/content_response.dart +++ b/lib/features/home/data/models/content_response.dart @@ -1,7 +1,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'cursor_model.dart'; -import 'place_model.dart'; +import 'package:mapsy_flutter/common/models/place_model.dart'; part 'content_response.freezed.dart'; part 'content_response.g.dart'; diff --git a/lib/features/home/data/models/place_model.freezed.dart b/lib/features/home/data/models/place_model.freezed.dart deleted file mode 100644 index 92f65e2..0000000 --- a/lib/features/home/data/models/place_model.freezed.dart +++ /dev/null @@ -1,415 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'place_model.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', -); - -PlaceModel _$PlaceModelFromJson(Map json) { - return _PlaceModel.fromJson(json); -} - -/// @nodoc -mixin _$PlaceModel { - /// 장소 ID - int get placeId => throw _privateConstructorUsedError; - - /// 장소명 - String get placeName => throw _privateConstructorUsedError; - - /// 주소 - String? get address => throw _privateConstructorUsedError; - - /// 위도 - double? get latitude => throw _privateConstructorUsedError; - - /// 경도 - double? get longitude => throw _privateConstructorUsedError; - - /// 카테고리 - String? get category => throw _privateConstructorUsedError; - - /// 태그 목록 - List get tags => throw _privateConstructorUsedError; - - /// 대표 이미지 URL - String? get imageUrl => throw _privateConstructorUsedError; - - /// 콘텐츠 ID (상위 콘텐츠) - int? get contentId => throw _privateConstructorUsedError; - - /// Serializes this PlaceModel to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of PlaceModel - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $PlaceModelCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $PlaceModelCopyWith<$Res> { - factory $PlaceModelCopyWith( - PlaceModel value, - $Res Function(PlaceModel) then, - ) = _$PlaceModelCopyWithImpl<$Res, PlaceModel>; - @useResult - $Res call({ - int placeId, - String placeName, - String? address, - double? latitude, - double? longitude, - String? category, - List tags, - String? imageUrl, - int? contentId, - }); -} - -/// @nodoc -class _$PlaceModelCopyWithImpl<$Res, $Val extends PlaceModel> - implements $PlaceModelCopyWith<$Res> { - _$PlaceModelCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of PlaceModel - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? placeId = null, - Object? placeName = null, - Object? address = freezed, - Object? latitude = freezed, - Object? longitude = freezed, - Object? category = freezed, - Object? tags = null, - Object? imageUrl = freezed, - Object? contentId = freezed, - }) { - return _then( - _value.copyWith( - placeId: null == placeId - ? _value.placeId - : placeId // ignore: cast_nullable_to_non_nullable - as int, - placeName: null == placeName - ? _value.placeName - : placeName // ignore: cast_nullable_to_non_nullable - as String, - address: freezed == address - ? _value.address - : address // ignore: cast_nullable_to_non_nullable - as String?, - latitude: freezed == latitude - ? _value.latitude - : latitude // ignore: cast_nullable_to_non_nullable - as double?, - longitude: freezed == longitude - ? _value.longitude - : longitude // ignore: cast_nullable_to_non_nullable - as double?, - category: freezed == category - ? _value.category - : category // ignore: cast_nullable_to_non_nullable - as String?, - tags: null == tags - ? _value.tags - : tags // ignore: cast_nullable_to_non_nullable - as List, - imageUrl: freezed == imageUrl - ? _value.imageUrl - : imageUrl // ignore: cast_nullable_to_non_nullable - as String?, - contentId: freezed == contentId - ? _value.contentId - : contentId // ignore: cast_nullable_to_non_nullable - as int?, - ) - as $Val, - ); - } -} - -/// @nodoc -abstract class _$$PlaceModelImplCopyWith<$Res> - implements $PlaceModelCopyWith<$Res> { - factory _$$PlaceModelImplCopyWith( - _$PlaceModelImpl value, - $Res Function(_$PlaceModelImpl) then, - ) = __$$PlaceModelImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({ - int placeId, - String placeName, - String? address, - double? latitude, - double? longitude, - String? category, - List tags, - String? imageUrl, - int? contentId, - }); -} - -/// @nodoc -class __$$PlaceModelImplCopyWithImpl<$Res> - extends _$PlaceModelCopyWithImpl<$Res, _$PlaceModelImpl> - implements _$$PlaceModelImplCopyWith<$Res> { - __$$PlaceModelImplCopyWithImpl( - _$PlaceModelImpl _value, - $Res Function(_$PlaceModelImpl) _then, - ) : super(_value, _then); - - /// Create a copy of PlaceModel - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? placeId = null, - Object? placeName = null, - Object? address = freezed, - Object? latitude = freezed, - Object? longitude = freezed, - Object? category = freezed, - Object? tags = null, - Object? imageUrl = freezed, - Object? contentId = freezed, - }) { - return _then( - _$PlaceModelImpl( - placeId: null == placeId - ? _value.placeId - : placeId // ignore: cast_nullable_to_non_nullable - as int, - placeName: null == placeName - ? _value.placeName - : placeName // ignore: cast_nullable_to_non_nullable - as String, - address: freezed == address - ? _value.address - : address // ignore: cast_nullable_to_non_nullable - as String?, - latitude: freezed == latitude - ? _value.latitude - : latitude // ignore: cast_nullable_to_non_nullable - as double?, - longitude: freezed == longitude - ? _value.longitude - : longitude // ignore: cast_nullable_to_non_nullable - as double?, - category: freezed == category - ? _value.category - : category // ignore: cast_nullable_to_non_nullable - as String?, - tags: null == tags - ? _value._tags - : tags // ignore: cast_nullable_to_non_nullable - as List, - imageUrl: freezed == imageUrl - ? _value.imageUrl - : imageUrl // ignore: cast_nullable_to_non_nullable - as String?, - contentId: freezed == contentId - ? _value.contentId - : contentId // ignore: cast_nullable_to_non_nullable - as int?, - ), - ); - } -} - -/// @nodoc -@JsonSerializable() -class _$PlaceModelImpl implements _PlaceModel { - const _$PlaceModelImpl({ - required this.placeId, - required this.placeName, - this.address, - this.latitude, - this.longitude, - this.category, - final List tags = const [], - this.imageUrl, - this.contentId, - }) : _tags = tags; - - factory _$PlaceModelImpl.fromJson(Map json) => - _$$PlaceModelImplFromJson(json); - - /// 장소 ID - @override - final int placeId; - - /// 장소명 - @override - final String placeName; - - /// 주소 - @override - final String? address; - - /// 위도 - @override - final double? latitude; - - /// 경도 - @override - final double? longitude; - - /// 카테고리 - @override - final String? category; - - /// 태그 목록 - final List _tags; - - /// 태그 목록 - @override - @JsonKey() - List get tags { - if (_tags is EqualUnmodifiableListView) return _tags; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_tags); - } - - /// 대표 이미지 URL - @override - final String? imageUrl; - - /// 콘텐츠 ID (상위 콘텐츠) - @override - final int? contentId; - - @override - String toString() { - return 'PlaceModel(placeId: $placeId, placeName: $placeName, address: $address, latitude: $latitude, longitude: $longitude, category: $category, tags: $tags, imageUrl: $imageUrl, contentId: $contentId)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$PlaceModelImpl && - (identical(other.placeId, placeId) || other.placeId == placeId) && - (identical(other.placeName, placeName) || - other.placeName == placeName) && - (identical(other.address, address) || other.address == address) && - (identical(other.latitude, latitude) || - other.latitude == latitude) && - (identical(other.longitude, longitude) || - other.longitude == longitude) && - (identical(other.category, category) || - other.category == category) && - const DeepCollectionEquality().equals(other._tags, _tags) && - (identical(other.imageUrl, imageUrl) || - other.imageUrl == imageUrl) && - (identical(other.contentId, contentId) || - other.contentId == contentId)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - placeId, - placeName, - address, - latitude, - longitude, - category, - const DeepCollectionEquality().hash(_tags), - imageUrl, - contentId, - ); - - /// Create a copy of PlaceModel - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$PlaceModelImplCopyWith<_$PlaceModelImpl> get copyWith => - __$$PlaceModelImplCopyWithImpl<_$PlaceModelImpl>(this, _$identity); - - @override - Map toJson() { - return _$$PlaceModelImplToJson(this); - } -} - -abstract class _PlaceModel implements PlaceModel { - const factory _PlaceModel({ - required final int placeId, - required final String placeName, - final String? address, - final double? latitude, - final double? longitude, - final String? category, - final List tags, - final String? imageUrl, - final int? contentId, - }) = _$PlaceModelImpl; - - factory _PlaceModel.fromJson(Map json) = - _$PlaceModelImpl.fromJson; - - /// 장소 ID - @override - int get placeId; - - /// 장소명 - @override - String get placeName; - - /// 주소 - @override - String? get address; - - /// 위도 - @override - double? get latitude; - - /// 경도 - @override - double? get longitude; - - /// 카테고리 - @override - String? get category; - - /// 태그 목록 - @override - List get tags; - - /// 대표 이미지 URL - @override - String? get imageUrl; - - /// 콘텐츠 ID (상위 콘텐츠) - @override - int? get contentId; - - /// Create a copy of PlaceModel - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$PlaceModelImplCopyWith<_$PlaceModelImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/features/home/data/models/place_model.g.dart b/lib/features/home/data/models/place_model.g.dart deleted file mode 100644 index bf5ad60..0000000 --- a/lib/features/home/data/models/place_model.g.dart +++ /dev/null @@ -1,35 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'place_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$PlaceModelImpl _$$PlaceModelImplFromJson(Map json) => - _$PlaceModelImpl( - placeId: (json['placeId'] as num).toInt(), - placeName: json['placeName'] as String, - address: json['address'] as String?, - latitude: (json['latitude'] as num?)?.toDouble(), - longitude: (json['longitude'] as num?)?.toDouble(), - category: json['category'] as String?, - tags: - (json['tags'] as List?)?.map((e) => e as String).toList() ?? - const [], - imageUrl: json['imageUrl'] as String?, - contentId: (json['contentId'] as num?)?.toInt(), - ); - -Map _$$PlaceModelImplToJson(_$PlaceModelImpl instance) => - { - 'placeId': instance.placeId, - 'placeName': instance.placeName, - 'address': instance.address, - 'latitude': instance.latitude, - 'longitude': instance.longitude, - 'category': instance.category, - 'tags': instance.tags, - 'imageUrl': instance.imageUrl, - 'contentId': instance.contentId, - }; diff --git a/lib/features/home/presentation/home_provider.dart b/lib/features/home/presentation/home_provider.dart index e420334..50a89f6 100644 --- a/lib/features/home/presentation/home_provider.dart +++ b/lib/features/home/presentation/home_provider.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../data/models/place_model.dart'; +import 'package:mapsy_flutter/common/models/place_model.dart'; import '../data/home_repository_impl.dart'; part 'home_provider.freezed.dart'; diff --git a/lib/features/home/presentation/widgets/place_card.dart b/lib/features/home/presentation/widgets/place_card.dart index ac4119a..a8c07bf 100644 --- a/lib/features/home/presentation/widgets/place_card.dart +++ b/lib/features/home/presentation/widgets/place_card.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../../../common/constants/home_colors.dart'; -import '../../data/models/place_model.dart'; +import 'package:mapsy_flutter/common/models/place_model.dart'; /// 장소 카드 (씀 스타일: 직각 보더, 넓은 여백) class PlaceCard extends StatelessWidget { diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index b25cef1..47c6068 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -24,6 +24,7 @@ import '../features/onboarding/presentation/pages/terms_step_page.dart'; import '../features/onboarding/presentation/pages/birth_date_step_page.dart'; import '../features/onboarding/presentation/pages/gender_step_page.dart'; import '../features/onboarding/presentation/pages/nickname_step_page.dart'; +import '../features/ai_extraction/presentation/pages/ai_extraction_page.dart'; /// GoRouter 인스턴스를 제공하는 Riverpod Provider /// @@ -175,6 +176,15 @@ final routerProvider = Provider((ref) { builder: (context, state) => const NicknameStepPage(), ), + // ==================================================================== + // AI Extraction Route + // ==================================================================== + GoRoute( + path: RoutePaths.aiExtraction, + name: RoutePaths.aiExtractionName, + builder: (context, state) => const AiExtractionPage(), + ), + // ==================================================================== // Home & Main Navigation (StatefulShellRoute - 4 tabs) // ==================================================================== diff --git a/lib/routing/route_paths.dart b/lib/routing/route_paths.dart index 8436d24..49db94c 100644 --- a/lib/routing/route_paths.dart +++ b/lib/routing/route_paths.dart @@ -69,6 +69,13 @@ class RoutePaths { /// 마이페이지 화면 (프로필, 설정) static const String mypage = '/mypage'; + // ============================================================================ + // AI Extraction Routes + // ============================================================================ + + /// AI 장소 추출 화면 + static const String aiExtraction = '/ai-extraction'; + // ============================================================================ // Route Names (for named navigation) // ============================================================================ @@ -85,4 +92,5 @@ class RoutePaths { static const String searchName = 'search'; static const String mapName = 'map'; static const String mypageName = 'mypage'; + static const String aiExtractionName = 'ai-extraction'; } diff --git a/pubspec.yaml b/pubspec.yaml index 4c42d5a..da628ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mapsy description: "MapSy - Flutter Application" publish_to: "none" -version: 1.0.41+41 +version: 1.0.42+42 environment: sdk: ^3.9.2 dependencies: diff --git a/version.yml b/version.yml index 13bcb65..cd843d1 100644 --- a/version.yml +++ b/version.yml @@ -34,11 +34,11 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "1.0.41" -version_code: 42 # app build number +version: "1.0.42" +version_code: 43 # app build number project_type: "flutter" # spring, flutter, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-02-23 02:11:39" + last_updated: "2026-02-23 04:13:40" last_updated_by: "Cassiiopeia" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE"