diff --git a/.gitignore b/.gitignore index fdfeb33..5306656 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ app.*.map.json # Coverage related coverage/ + +# API +.env \ No newline at end of file diff --git a/integration_test/ocr_provider_test.dart b/integration_test/ocr_provider_test.dart index 8d5d163..fc89c55 100644 --- a/integration_test/ocr_provider_test.dart +++ b/integration_test/ocr_provider_test.dart @@ -20,7 +20,7 @@ void main() { setUp(() { provider = OCRProviderImpl( - baseUrl: 'https://901b-39-115-116-188.ngrok-free.app', + baseUrl: 'https://b08b-165-194-27-212.ngrok-free.app', ); }); @@ -28,12 +28,8 @@ void main() { final pdfPath = await copyAssetToLocal('integration_test/assets/test.pdf'); final result = await provider.process(pdfPath); expect(result, isA()); - expect(result.title, isNotEmpty); - expect(result.chapters, isNotEmpty); expect(result.pages, isNotEmpty); - expect(result.metadata.totalPages, isNotEmpty); - expect(result.metadata.processingTime, isNotEmpty); - expect(result.metadata.totalFigures, isNotEmpty); + expect(result.figures, isNotEmpty); }, timeout: const Timeout(Duration(minutes: 15))); test('process()에 잘못된 경로를 넣어도 예외가 발생하지 않는다', () async { diff --git a/integration_test/pdf_provider_test.dart b/integration_test/pdf_provider_test.dart index a665929..17949a0 100644 --- a/integration_test/pdf_provider_test.dart +++ b/integration_test/pdf_provider_test.dart @@ -20,12 +20,10 @@ Future copyAssetToLocal(String assetPath) async { class DummyOcrService implements OCRProvider { @override Future process(String pdfPath) async { - return OCRResult( - title: 'test', - chapters: [], + return const OCRResult( + metadata: Metadata(title: 'test', author: 'test', pages: 0), pages: [], figures: [], - metadata: Metadata(totalPages: 0, processingTime: 0, totalFigures: 0), ); } } diff --git a/lib/features/home/screens/home_widget.dart b/lib/features/home/screens/home_widget.dart index c8603d9..74f44a8 100644 --- a/lib/features/home/screens/home_widget.dart +++ b/lib/features/home/screens/home_widget.dart @@ -36,6 +36,8 @@ class _HomeWidgetState extends State { } Widget _buildNavigationRail(BuildContext context) { + final theme = Theme.of(context); + return NavigationRail( useIndicator: true, labelType: NavigationRailLabelType.all, @@ -48,6 +50,31 @@ class _HomeWidgetState extends State { setState(() => _selectedIndex = index); }, minWidth: 80, + trailing: Expanded( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () { + Navigator.of(context).pushNamed('/ai-settings'); + }, + icon: Icon(Icons.smart_toy, color: theme.colorScheme.primary), + tooltip: 'AI 설정', + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primaryContainer, + foregroundColor: theme.colorScheme.onPrimaryContainer, + padding: const EdgeInsets.all(12), + ), + ), + ], + ), + ), + ), + ), ); } diff --git a/lib/features/pdf_viewer/models/figure_info.dart b/lib/features/pdf_viewer/models/figure_info.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/pdf_viewer/models/ocr_text_block.dart b/lib/features/pdf_viewer/models/ocr_text_block.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart b/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart new file mode 100644 index 0000000..e8164cd --- /dev/null +++ b/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:snapfig/shared/services/pdf_core/pdf_core.dart'; +import 'package:pdfrx/pdfrx.dart'; +import 'package:collection/collection.dart'; +import 'dart:ui' as ui; + +class PDFDataViewModel extends ChangeNotifier { + final PDFProvider _pdfProvider; + // Pdf Ocr result + BasePdf? _pdfModel; + List _pages = []; + Map> _layoutsByPage = {}; + // Pdf data + PdfDocument? _pdfDocument; + bool _isDataLoaded = false; + bool _isLoading = true; + bool _isError = false; + String? _errorMessage; + List _outlines = []; + + bool get isDataLoaded => _isDataLoaded; + bool get isLoading => _isLoading; + bool get isFailed => _isError; + String? get errorMessage => _errorMessage; + List get outlines => _outlines; + Map> get layoutsByPage => _layoutsByPage; + List get pages => _pages; + List get figures => + _layoutsByPage.values + .expand((e) => e) + .where((e) => e.type == LayoutType.figure) + .toList(); + String get pdfTitle => _pdfModel?.name ?? ''; + + PDFDataViewModel({required PDFProvider pdfProvider}) + : _pdfProvider = pdfProvider; + + @override + void dispose() { + super.dispose(); + } + + void loadPDFData(String path, PdfDocument pdfDocument) async { + if (_isDataLoaded) return; + _pdfDocument = pdfDocument; + _isLoading = true; + notifyListeners(); + try { + // Load pdf model from database + final pdfModel = await _pdfProvider.getPDF(path); + if (pdfModel == null) { + _isError = true; + _errorMessage = 'OCR처리가 완료되지 않은 파일입니다.'; + notifyListeners(); + return; + } + _pages = await pdfModel.getPages(); + _pdfModel = pdfModel; + _outlines = await _pdfDocument!.loadOutline(); + _layoutsByPage = await Future.wait( + _pages.map((page) async { + final layouts = await page.getLayouts(); + return {page.pageIndex: layouts.toList()}; + }), + ).then((value) => Map.fromEntries(value.expand((e) => e.entries))); + _isLoading = false; + notifyListeners(); + } catch (e) { + _isError = true; + _errorMessage = e.toString(); + } + _isLoading = false; + _isDataLoaded = true; + notifyListeners(); + } + + int? getPageNumberForFigure(BaseLayout figure) { + if (figure.type != LayoutType.figure) return null; + return _layoutsByPage.entries + .firstWhereOrNull((entry) => entry.value.contains(figure)) + ?.key; + } + + int? getPageNumberForReference(BaseLayout reference) { + if (reference.type != LayoutType.figureReference) return null; + return _layoutsByPage.entries + .firstWhereOrNull((entry) => entry.value.contains(reference)) + ?.key; + } + + BaseLayout? findFigureByReference(BaseLayout reference) { + if (reference.type != LayoutType.figureReference) return null; + final referencedId = reference.referencedFigureId; + if (referencedId == null) return null; + return _layoutsByPage.entries + .expand((entry) => entry.value) + .firstWhereOrNull((layout) => layout.figureId == referencedId); + } + + Future getFigureImage(BaseLayout figure) async { + if (figure.type != LayoutType.figure || _pdfDocument == null) { + print( + '❌ Invalid input: figure type=${figure.type}, pdfDocument=${_pdfDocument != null}', + ); + return null; + } + + final pageIndex = getPageNumberForFigure(figure); + if (pageIndex == null) { + print('❌ Could not find page for figure: ${figure.figureId}'); + return null; + } + + // Validate page index bounds + if (pageIndex < 0 || pageIndex >= _pages.length) { + print( + '❌ Page index out of bounds: $pageIndex (total pages: ${_pages.length})', + ); + return null; + } + + // Convert 0-based index to 1-based page number for PDF document lookup + final pageNumber = pageIndex + 1; + final pageDoc = _pdfDocument!.pages.firstWhereOrNull( + (p) => p.pageNumber == pageNumber, + ); + if (pageDoc == null) { + print('❌ Could not find PDF page: $pageNumber'); + return null; + } + + print('✅ Processing figure on page $pageNumber (index: $pageIndex)'); + print(' PDF page size: ${pageDoc.width}x${pageDoc.height}'); + print( + ' OCR page size: ${_pages[pageIndex].width}x${_pages[pageIndex].height}', + ); + + // Calculate scale factors + final scaleX = pageDoc.width / _pages[pageIndex].width; + final scaleY = pageDoc.height / _pages[pageIndex].height; + + print(' Scale factors: scaleX=$scaleX, scaleY=$scaleY'); + + final figureRect = figure.rect; + + // Add padding around the figure to prevent cropping (10% of figure size, minimum 5 pixels) + final paddingX = (figureRect.width * 0.1).clamp(5.0, 20.0); + final paddingY = (figureRect.height * 0.1).clamp(5.0, 20.0); + + // Apply padding to the original rectangle + final paddedLeft = (figureRect.left - paddingX) * scaleX; + final paddedTop = (figureRect.top - paddingY) * scaleY; + final paddedWidth = (figureRect.width + paddingX * 2) * scaleX; + final paddedHeight = (figureRect.height + paddingY * 2) * scaleY; + + print(' Original rect: ${figureRect.toString()}'); + print( + ' Padded rect: left=$paddedLeft, top=$paddedTop, width=$paddedWidth, height=$paddedHeight', + ); + + // Validate dimensions + if (paddedWidth <= 0 || paddedHeight <= 0) { + print('❌ Invalid dimensions after scaling and padding'); + return null; + } + + // Improved clamping that preserves as much of the figure as possible + final clampedLeft = paddedLeft.clamp(0.0, pageDoc.width.toDouble()); + final clampedTop = paddedTop.clamp(0.0, pageDoc.height.toDouble()); + + // Calculate maximum available width/height from clamped position + final maxAvailableWidth = pageDoc.width - clampedLeft; + final maxAvailableHeight = pageDoc.height - clampedTop; + + // Use the smaller of padded dimensions or available space + final clampedWidth = paddedWidth.clamp(1.0, maxAvailableWidth); + final clampedHeight = paddedHeight.clamp(1.0, maxAvailableHeight); + + if (clampedLeft != paddedLeft || + clampedTop != paddedTop || + clampedWidth != paddedWidth || + clampedHeight != paddedHeight) { + print( + '⚠️ Adjusted coordinates: left=$clampedLeft, top=$clampedTop, width=$clampedWidth, height=$clampedHeight', + ); + } + + // Use round() instead of toInt() for better precision + final renderX = clampedLeft.round(); + final renderY = clampedTop.round(); + final renderWidth = clampedWidth.round(); + final renderHeight = clampedHeight.round(); + + print( + ' Final render params: x=$renderX, y=$renderY, width=$renderWidth, height=$renderHeight', + ); + + final image = await pageDoc.render( + x: renderX, + y: renderY, + width: renderWidth, + height: renderHeight, + ); + if (image == null) { + print('❌ Failed to render image'); + return null; + } + final imageData = await image.createImage(); + print('✅ Successfully rendered figure image'); + return imageData; + } +} diff --git a/lib/features/pdf_viewer/models/pdf_document_model.dart b/lib/features/pdf_viewer/models/pdf_document_model.dart deleted file mode 100644 index 5fb2f4d..0000000 --- a/lib/features/pdf_viewer/models/pdf_document_model.dart +++ /dev/null @@ -1,100 +0,0 @@ -// lib/shared/services/pdf_core/models/pdf_document_model.dart - -import 'dart:convert'; -import 'package:flutter/services.dart'; -import 'package:pdfrx/pdfrx.dart'; -import 'package:isar/isar.dart'; - -import '../../../shared/services/pdf_core/models/interface/base_pdf.dart'; -import '../../../shared/services/pdf_core/models/interface/base_page.dart'; -import 'pdf_page_model.dart'; -import 'pdf_layout_model.dart'; - -/// PDF 데이터 모델 구현체 -class PdfDocumentModel implements BasePdf { - final String _name; - final String _filePath; - final List _pages; - - PdfDocumentModel._(this._name, this._filePath, this._pages); - - /// PdfDocument → PdfDocumentModel - /// - name: 파일명 - /// - filePath: 경로 또는 asset 식별자 - static Future fromDocument( - PdfDocument doc, { - required String name, - required String filePath, - }) async { - // 1) OCR/파싱 JSON 불러오기 - final raw = await rootBundle.loadString('assets/ocr_result.json'); - final ocr = json.decode(raw) as Map; - - final parsingBlocks = - (ocr['parsing_res_list'] as List).cast>(); - final formulaBlocks = - (ocr['formula_res_list'] as List).cast>(); - - // 2) 페이지별 레이아웃 모으기 - final pages = []; - for (var i = 0; i < doc.pages.length; i++) { - final layouts = []; - - for (final b in parsingBlocks) { - final idx = b['page_index'] is int ? b['page_index'] as int : 0; - if (idx == i) { - layouts.add(PdfLayoutModel.fromParsingBlock(b, i)); - } - } - - for (final b in formulaBlocks) { - final idx = b['page_index'] is int ? b['page_index'] as int : 0; - if (idx == i) { - layouts.add(PdfLayoutModel.fromFormulaBlock(b, i)); - } - } - - pages.add(PdfPageModel(document: doc, pageIndex: i, layouts: layouts)); - } - - return PdfDocumentModel._(name, filePath, pages); - } - - // --- BasePdf 구현 --- - - /// 고유 식별자: 여기서는 파일 경로 해시코드를 사용 - @override - Id get id => _filePath.hashCode; - - @override - String get name => _name; - - @override - String get path => _filePath; - - @override - DateTime get createdAt => DateTime.now(); - - @override - DateTime get updatedAt => DateTime.now(); - - @override - int get totalPages => _pages.length; - - @override - int get currentPage => 0; // UI에서 별도 관리하도록 대체 가능 - - @override - PDFStatus get status => PDFStatus.completed; - - /// 썸네일 바이트 (null로 두거나, 미리 렌더링한 바이트를 리턴할 수 있습니다) - @override - List? get thumbnail => null; - - /// 페이지 목록을 Future로 반환하도록 시그니처 변경 - @override - Future> getPages() async { - // 이미 PdfPageModel 타입의 리스트이므로 그대로 반환 - return _pages; - } -} diff --git a/lib/features/pdf_viewer/models/pdf_layout_model.dart b/lib/features/pdf_viewer/models/pdf_layout_model.dart deleted file mode 100644 index 98983fc..0000000 --- a/lib/features/pdf_viewer/models/pdf_layout_model.dart +++ /dev/null @@ -1,125 +0,0 @@ -// lib/shared/services/pdf_core/models/pdf_layout_model.dart - -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; // ← Flutter 위젯, Image, FutureBuilder, SizedBox, Center, CircularProgressIndicator 등 -import 'package:isar/isar.dart'; - -import '../../../shared/services/pdf_core/models/interface/base_layout.dart'; -import 'pdf_page_model.dart'; - -/// PDF 문서 내 레이아웃(텍스트, 수식, 헤더, 알고리즘 등)을 나타내는 모델 -class PdfLayoutModel implements BaseLayout { - /// 이 레이아웃이 속한 페이지 인덱스 (0-based) - final int pageIndex; - - @override - final LayoutType type; - - @override - final String content; - - @override - final String? text; - - @override - final String? latex; - - @override - final Rect rect; - - PdfLayoutModel({ - required this.pageIndex, - required this.type, - required this.content, - this.text, - this.latex, - required this.rect, - }); - - /// 고유 식별자: 페이지 인덱스와 콘텐츠 해시를 조합해 생성 - @override - Id get id => pageIndex.hashCode ^ content.hashCode; - - /// (선택) BaseLayout에 thumbnail 필드가 있다면 구현, - /// 없다면 그냥 null 반환 - List? get thumbnail => null; - - /// OCR 파싱 결과 블록에서 모델 생성 - factory PdfLayoutModel.fromParsingBlock( - Map block, - int pageIndex, - ) { - final label = block['block_label'] as String; - final type = LayoutType.values.firstWhere( - (e) => e.toString().endsWith(label), - orElse: () => LayoutType.text, - ); - final bbox = (block['block_bbox'] as List).cast(); - return PdfLayoutModel( - pageIndex: pageIndex, - type: type, - content: block['block_content'] as String, - text: type == LayoutType.text ? block['block_content'] as String : null, - latex: - type == LayoutType.formula ? block['block_content'] as String : null, - rect: Rect.fromLTWH( - bbox[0].toDouble(), - bbox[1].toDouble(), - (bbox[2] - bbox[0]).toDouble(), - (bbox[3] - bbox[1]).toDouble(), - ), - ); - } - - /// 수식 인식 결과 블록에서 모델 생성 - factory PdfLayoutModel.fromFormulaBlock( - Map block, - int pageIndex, - ) { - final polys = (block['dt_polys'] as List).cast(); - return PdfLayoutModel( - pageIndex: pageIndex, - type: LayoutType.formula, - content: block['rec_formula'] as String, - text: null, - latex: block['rec_formula'] as String, - rect: Rect.fromLTWH( - polys[0].toDouble(), - polys[1].toDouble(), - (polys[2] - polys[0]).toDouble(), - (polys[3] - polys[1]).toDouble(), - ), - ); - } - - /// 이 레이아웃 블록 영역만 잘라낸 썸네일 위젯 - Widget thumbnailWidget({double width = 60, double height = 80}) { - return FutureBuilder( - future: PdfPageModel.getThumbnailBytes( - pageIndex, - width.toInt(), - height.toInt(), - ), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - return Image.memory( - snapshot.data!, - width: width, - height: height, - fit: BoxFit.cover, - ); - } else { - return SizedBox( - width: width, - height: height, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - } - }, - ); - } -} diff --git a/lib/features/pdf_viewer/models/pdf_page_model.dart b/lib/features/pdf_viewer/models/pdf_page_model.dart deleted file mode 100644 index 8702e81..0000000 --- a/lib/features/pdf_viewer/models/pdf_page_model.dart +++ /dev/null @@ -1,109 +0,0 @@ -// lib/shared/services/pdf_core/models/pdf_page_model.dart - -import 'dart:typed_data'; -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:pdfrx/pdfrx.dart'; -import 'package:isar/isar.dart'; - -import '../../../shared/services/pdf_core/models/interface/base_page.dart'; -import '../../../shared/services/pdf_core/models/interface/base_layout.dart'; -import 'package:snapfig/shared/services/pdf_service.dart'; - -/// BasePage 인터페이스 구현체 -class PdfPageModel implements BasePage { - /// 0-based 페이지 인덱스 - @override - final int pageIndex; - - final PdfDocument _document; - final List _layouts; - - PdfPageModel({ - required PdfDocument document, - required this.pageIndex, - required List layouts, - }) : _document = document, - _layouts = layouts; - - /// --- BasePage 구현 --- - - /// 고유 식별자: 페이지 인덱스를 그대로 사용하거나, - /// 필요시 hash 조합 등으로 바꿀 수 있습니다. - @override - Id get id => pageIndex; - - /// 페이지 크기 - @override - Size get size => - Size(_document.pages[pageIndex].width, _document.pages[pageIndex].height); - - /// 페이지 전체 텍스트 (예: OCR 연동 필요 시 교체) - @override - String get fullText => ''; - - /// 레이아웃 목록을 Future 형태로 반환 - @override - Future> getLayouts() async => _layouts; - - /// --- 썸네일 기능 --- - - /// 화면에 보여줄 페이지 축소판 위젯 - Widget thumbnailWidget({double width = 80, double height = 120}) { - return FutureBuilder( - future: getThumbnailBytes(pageIndex, width.toInt(), height.toInt()), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - return ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.memory( - snapshot.data!, - width: width, - height: height, - fit: BoxFit.contain, // 전체가 보이도록 - alignment: Alignment.topCenter, - ), - ); - } - return SizedBox( - width: width, - height: height, - child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), - ); - }, - ); - } - - /// 전역 PdfService.instance.currentDocument 에서 - /// [pageIndex] 페이지를 [w]×[h] 크기로 렌더링하고 - /// PNG 바이트로 반환하는 정적 헬퍼 - static Future getThumbnailBytes( - int pageIndex, - int w, - int h, - ) async { - final doc = PdfService.instance.currentDocument; - if (doc == null) { - throw StateError('PDF 문서가 아직 로드되지 않았습니다.'); - } - - final PdfImage? pdfImage = await doc.pages[pageIndex].render( - width: w, - height: h, - ); - if (pdfImage == null) { - throw StateError('페이지 렌더링 실패 index=$pageIndex'); - } - - final ui.Image img = await pdfImage.createImage(); - pdfImage.dispose(); - - final ByteData? bd = await img.toByteData(format: ui.ImageByteFormat.png); - if (bd == null) { - throw StateError('PNG 바이트 변환 실패 index=$pageIndex'); - } - return bd.buffer.asUint8List(); - } -} diff --git a/lib/features/pdf_viewer/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index a507a36..eb33eaa 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -1,90 +1,157 @@ import 'package:flutter/material.dart'; import 'package:pdfrx/pdfrx.dart'; +import 'package:snapfig/features/pdf_viewer/models/pdf_data_viewmodel.dart'; +import 'package:snapfig/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart'; +import 'package:snapfig/features/pdf_viewer/widgets/figure_overlay_widget.dart'; +import 'package:snapfig/features/pdf_viewer/widgets/popover_wrapper.dart'; +import 'package:snapfig/features/pdf_viewer/widgets/simple_draggable_ai_chat.dart'; +import 'package:snapfig/features/pdf_viewer/widgets/figure_highlight_widget.dart'; +import 'package:snapfig/shared/services/ai_service/ai_service.dart'; import '../../../shared/services/pdf_core/pdf_core.dart'; -import '../models/pdf_viewer_view_model.dart'; -import '../widgets/pdf_sidebar.dart'; -import '../widgets/no_pdf_loaded_view.dart'; import '../widgets/pdf_bottom_bar.dart'; -// 매직 넘버 상수 선언 -const double kStatusBarHeight = 48; -const double kIconSize = 64; -const double kFontSize = 18; -const double kSpacingLarge = 16; -const double kSpacingSmall = 8; - class PDFViewer extends StatefulWidget { final String path; - final bool isAsset; - const PDFViewer({super.key, required this.path, this.isAsset = true}); + const PDFViewer({super.key, required this.path}); @override State createState() => _PDFViewerState(); } class _PDFViewerState extends State { - PdfViewerViewModel? _viewModel; + PDFDataViewModel? _viewModel; final PdfViewerController _pdfController = PdfViewerController(); + bool _sidebarVisible = true; + OverlayEntry? _currentPopover; @override void initState() { super.initState(); + // Initialize AI service + AIService.instance.loadConfigurations(); + // Get PDFProvider from InheritedWidget after the first frame WidgetsBinding.instance.addPostFrameCallback((_) { final pdfProvider = InheritedPDFProviderWidget.of(context).provider; setState(() { - _viewModel = PdfViewerViewModel(pdfProvider: pdfProvider); + _viewModel = PDFDataViewModel(pdfProvider: pdfProvider); + }); + _pdfController.addListener(() { + if (_pdfController.isReady) { + _pdfController.useDocument((doc) { + _viewModel?.loadPDFData(widget.path, doc); + }); + setState(() {}); + } }); - - // Load initial PDF - _viewModel!.loadPdf(path: widget.path, isAsset: widget.isAsset); }); - - // Listen to PDF controller for page changes - _pdfController.addListener(_onPdfControllerChanged); } - void _onPdfControllerChanged() { - if (_pdfController.isReady) { - // Update current page in view model - final pageNumber = _pdfController.pageNumber ?? 1; - _viewModel?.updateCurrentPage(pageNumber); + void _onOutlineSelected(PdfOutlineNode outline) { + if (outline.dest == null) return; + _pdfController.goToPage(pageNumber: outline.dest!.pageNumber); + } - // Update zoom in view model - final zoomRatio = _pdfController.currentZoom; - _viewModel?.updateZoom(zoomRatio); - } + void _onFigureSelected(BaseLayout layout) { + // Show figure overlay popup when reference is tapped + if (layout.type != LayoutType.figure) return; + final pageNumber = _viewModel!.getPageNumberForFigure(layout); + if (pageNumber == null) return; + _pdfController.goToPage(pageNumber: pageNumber); } - void _onPageSelected(BasePage page) { - // Jump to the selected page (page.pageIndex is 0-based, but controller expects 1-based) - _pdfController.goToPage(pageNumber: page.pageIndex + 1); + void _showFigurePopover(BaseLayout reference, Offset tapPosition) { + // Dismiss any existing popover + _dismissCurrentPopover(); + + final overlay = Overlay.of(context); + + final overlayEntry = OverlayEntry( + builder: + (context) => PopoverWrapper( + targetPosition: tapPosition, + onDismiss: _dismissCurrentPopover, + child: FigureOverlayWidget( + reference: reference, + viewModel: _viewModel!, + onClose: _dismissCurrentPopover, + navigateToFigure: (figure) { + _dismissCurrentPopover(); + _navigateToFigure(figure); + }, + onAskAI: (figure) { + _dismissCurrentPopover(); + _showAIChatPopup(figure); + }, + ), + ), + ); + + _currentPopover = overlayEntry; + overlay.insert(overlayEntry); } - void _onLayoutSelected(BaseLayout layout) { - // Navigate to the page containing this figure - if (_viewModel != null && layout.type == LayoutType.figure) { - final pageNumber = _viewModel!.getPageNumberForFigure(layout); - if (pageNumber != null) { - _pdfController.goToPage(pageNumber: pageNumber); - } else { - // Show error message if figure page not found - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Unable to navigate to figure'), - duration: Duration(seconds: 2), + void _showAIChatPopup(BaseLayout figure) { + // Dismiss any existing popover + _dismissCurrentPopover(); + + final overlay = Overlay.of(context); + final screenSize = MediaQuery.of(context).size; + + // Calculate initial position (center of screen) + const chatWidth = 320.0; + const chatHeight = 400.0; + final initialPosition = Offset( + (screenSize.width - chatWidth) / 2, // Center horizontally + (screenSize.height - chatHeight) / 2, // Center vertically + ); + + final overlayEntry = OverlayEntry( + builder: + (context) => SimpleDraggableAIChat( + figure: figure, + viewModel: _viewModel!, + onClose: _dismissCurrentPopover, + initialPosition: initialPosition, ), - ); - } + ); + + _currentPopover = overlayEntry; + overlay.insert(overlayEntry); + } + + void _dismissCurrentPopover() { + _currentPopover?.remove(); + _currentPopover = null; + } + + void _navigateToFigure(BaseLayout figure) { + final pageNumber = _viewModel!.getPageNumberForFigure(figure); + if (pageNumber != null) { + _pdfController.goToPage(pageNumber: pageNumber + 1); + } else { + // Show error message if figure page not found + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Unable to navigate to figure'), + duration: Duration(seconds: 2), + ), + ); } } + void _onSidebarToggle() { + setState(() { + _sidebarVisible = !_sidebarVisible; + }); + } + @override void dispose() { - _pdfController.removeListener(_onPdfControllerChanged); + _dismissCurrentPopover(); _viewModel?.dispose(); super.dispose(); } @@ -99,19 +166,19 @@ class _PDFViewerState extends State { return ListenableBuilder( listenable: _viewModel!, builder: (context, child) { - // 1. 로딩 상태 - if (_viewModel!.isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } - // 2. PDF 미로딩 상태 - if (_viewModel!.errorMessage != null) { - return const Scaffold(body: NoPdfLoadedView()); - } - // 3. 정상 상태 return Scaffold( - appBar: AppBar(title: Text(_viewModel!.pdfTitle), actions: []), + appBar: AppBar( + title: Text(_viewModel!.pdfTitle), + actions: [ + IconButton( + onPressed: () { + Navigator.of(context).pushNamed('/ai-settings'); + }, + icon: const Icon(Icons.smart_toy), + tooltip: 'AI 설정', + ), + ], + ), body: Row( children: [ Expanded( @@ -126,22 +193,35 @@ class _PDFViewerState extends State { backgroundColor: Theme.of(context).colorScheme.surfaceContainer, pageOverlaysBuilder: (context, pageRect, page) { - return _buildPageOverlays(context, pageRect, page); + print('pageOverlaysBuilder: ${page.pageNumber}'); + return _buildReferenceHighlights( + context, + pageRect, + page.pageNumber, + ); }, ), ), ), - // Bottom Status Bar - PDFBottomBar( - zoomPercent: _viewModel!.zoomPercent.toInt(), - currentPage: _viewModel!.currentPage, - totalPages: _viewModel!.pages.length, - sidebarVisible: _viewModel!.sidebarVisible, - onSidebarToggle: _viewModel!.toggleSidebar, - ), + if (_pdfController.isReady) + // Bottom Status Bar + PDFBottomBar( + zoomPercent: (_pdfController.currentZoom * 100).toInt(), + currentPage: _pdfController.pageNumber ?? 1, + totalPages: _viewModel!.pages.length, + sidebarVisible: _sidebarVisible, + onSidebarToggle: _onSidebarToggle, + ), ], ), ), + // Divider between main content and sidebar + if (_sidebarVisible) + VerticalDivider( + width: 1, + thickness: 1, + color: Theme.of(context).colorScheme.outline, + ), // Sidebar AnimatedSwitcher( duration: const Duration(milliseconds: 400), @@ -158,13 +238,8 @@ class _PDFViewerState extends State { ); }, child: - _viewModel!.sidebarVisible - ? PdfSidebar( - key: const ValueKey('sidebar'), - viewModel: _viewModel!, - onPageSelected: _onPageSelected, - onLayoutSelected: _onLayoutSelected, - ) + _sidebarVisible + ? _buildSideBar(context) : const SizedBox.shrink(key: ValueKey('empty')), ), ], @@ -174,546 +249,140 @@ class _PDFViewerState extends State { ); } - List _buildPageOverlays( + Widget _buildSideBar(BuildContext context) { + final theme = Theme.of(context); + if (_viewModel?.isDataLoaded ?? false) { + return PDFSideBar( + viewModel: _viewModel!, + onPageSelected: _onOutlineSelected, + onFigureSelected: _onFigureSelected, + ); + } else if (_viewModel?.isLoading ?? false) { + return Container( + width: 300, + color: theme.colorScheme.surfaceContainer, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text('Loading PDF...', style: theme.textTheme.bodyMedium), + ], + ), + ), + ); + } else { + return Container( + color: theme.colorScheme.surfaceContainer, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'PDF data not loaded', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ), + ), + ); + } + } + + // Reference highlight + List _buildReferenceHighlights( BuildContext context, Rect pageRect, - PdfPage page, + int pageNumber, ) { - // Get the current page index (PdfPage.pageNumber is 1-based) - final pageIndex = page.pageNumber - 1; - - // Check if we have view model - if (_viewModel == null) { - return []; - } - - // Get the corresponding PageModel from our model - if (pageIndex >= _viewModel!.pages.length) { - return []; - } - final pageModel = _viewModel!.pages.firstWhere( - (page) => page.pageIndex == pageIndex, + if (_viewModel == null || _viewModel!.isLoading) return []; + final pageIndex = pageNumber - 1; + final layouts = _viewModel!.layoutsByPage[pageIndex]; + final page = _viewModel!.pages[pageIndex]; + if (layouts == null) return []; + // Include both figure references and actual figures + final references = layouts.where( + (layout) => + layout.type == LayoutType.figureReference || + layout.type == LayoutType.figure, ); - // Get figure references for this page - final figureReferences = _viewModel!.getFigureReferencesForPage(pageIndex); - - if (figureReferences.isEmpty) { - return []; - } + // 페이지 크기 비율 계산 - OCR 좌표를 뷰어 좌표로 변환 + final scaleX = pageRect.width / page.width.toDouble(); + final scaleY = pageRect.height / page.height.toDouble(); - // scaleX, scaleY 각각 적용 - final scaleX = pageRect.width / pageModel.width.toDouble(); - final scaleY = pageRect.height / pageModel.height.toDouble(); + print('=== Page $pageNumber Highlights Debug ==='); + print('References found: ${references.length}'); + print('OCR page size: ${page.width}x${page.height}'); + print('PageRect: ${pageRect.toString()}'); + print('PageRect size: ${pageRect.width}x${pageRect.height}'); + print('PageRect offset: ${pageRect.left}, ${pageRect.top}'); + print('Scale factors: scaleX=$scaleX, scaleY=$scaleY'); - return figureReferences.map((reference) { + return references.map((reference) { final rect = reference.rect; - // Direct coordinate transformation without Y-axis flip - // The OCR coordinates appear to use top-left origin like Flutter - final left = (rect.left * scaleX); - final top = (rect.top * scaleY); + // Transform OCR coordinates to page coordinates + final left = rect.left * scaleX; + final top = rect.top * scaleY; final width = rect.width * scaleX; final height = rect.height * scaleY; + // Debug output + print(' Figure: ${reference.content}'); + print(' OCR rect: ${rect.toString()}'); + print(' Scaled: left=$left, top=$top, width=$width, height=$height'); + print(' PageRect: ${pageRect.toString()}'); + + // Basic validation + if (width <= 0 || height <= 0) { + print(' ❌ Invalid dimensions, skipping'); + return const SizedBox.shrink(); + } + + // Skip if completely outside page bounds + if (left + width < 0 || + left > pageRect.width || + top + height < 0 || + top > pageRect.height) { + print(' ❌ Completely outside page bounds, skipping'); + return const SizedBox.shrink(); + } + return Positioned( left: left, top: top, width: width, height: height, - child: Builder( - builder: (innerContext) { - return GestureDetector( - onTap: () { - // Get the render box of this specific widget - final RenderBox renderBox = - innerContext.findRenderObject() as RenderBox; - // Calculate center position in local coordinates - final localCenter = Offset(width / 2, height / 2); - // Convert to global coordinates - final globalPosition = renderBox.localToGlobal(localCenter); - _showFigurePopup(context, reference, globalPosition); - }, - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of( - context, - ).colorScheme.secondary.withOpacity(0.7), - width: 2, - ), - borderRadius: BorderRadius.circular(4), - color: Theme.of( - context, - ).colorScheme.secondary.withOpacity(0.1), - ), - ), - ); - }, - ), + child: _buildHighlightWidget(context, reference), ); }).toList(); } - Future _showFigurePopup( - BuildContext context, - BaseLayout reference, - Offset globalPosition, - ) async { - // Find the corresponding figure - final figure = _viewModel?.findFigureByReference(reference); - if (figure == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Figure not found: ${reference.referenceText}'), - duration: const Duration(seconds: 2), - ), - ); - return; - } - - // Find which page contains the figure - final figurePage = _viewModel?.getPageForLayout(figure); - if (figurePage == null) return; - - // Show the figure in a custom overlay - final overlay = Overlay.of(context); - late OverlayEntry overlayEntry; - - overlayEntry = OverlayEntry( - builder: - (context) => _FigurePopupOverlay( - figure: figure, - figurePage: figurePage, - targetPosition: globalPosition, - onClose: () => overlayEntry.remove(), - renderFigure: _renderFigure, - ), - ); - - overlay.insert(overlayEntry); - } - - Future _renderFigure(int pageIndex, BaseLayout figure) async { - try { - // Load the PDF document - final document = await PdfDocument.openFile(widget.path); - - // Get the specific page (pageIndex is 0-based) - final page = document.pages[pageIndex]; - - // Get page dimensions from the PageModel (OCR coordinates) - final pageModel = _viewModel!.pages.firstWhere( - (p) => p.pageIndex == pageIndex, - ); - final ocrPageWidth = pageModel.width.toDouble(); - final ocrPageHeight = pageModel.height.toDouble(); - - // Get the figure's bounding box (in OCR coordinates) - final figureRect = figure.rect; - - // Calculate scale to render at a reasonable resolution - final targetWidth = 1000.0; // Target width for full page render - final scale = targetWidth / ocrPageWidth; - - // Calculate the scaled dimensions - final scaledPageWidth = (ocrPageWidth * scale); - final scaledPageHeight = (ocrPageHeight * scale); - - // Calculate the figure position and size in the scaled coordinates - final scaledX = (figureRect.left * scale).toInt(); - final scaledY = (figureRect.top * scale).toInt(); - final scaledWidth = (figureRect.width * scale).toInt(); - final scaledHeight = (figureRect.height * scale).toInt(); - - // Render the sub-area of the page - final renderResult = await page.render( - x: scaledX, - y: scaledY, - width: scaledWidth, - height: scaledHeight, - fullWidth: scaledPageWidth, - fullHeight: scaledPageHeight, - ); - - if (renderResult == null) { - await document.dispose(); - return const Text('Failed to render page'); - } - - final image = await renderResult.createImage(); - - // Clean up - renderResult.dispose(); - await document.dispose(); - - if (image != null) { - return InteractiveViewer( - panEnabled: true, - minScale: 0.5, - maxScale: 4.0, - child: RawImage(image: image, fit: BoxFit.contain), - ); - } - - return const Text('Failed to create image'); - } catch (e) { - return Text('Error: $e'); - } - } -} - -// Custom overlay widget for the figure popup with pin -class _FigurePopupOverlay extends StatefulWidget { - final BaseLayout figure; - final BasePage figurePage; - final Offset targetPosition; - final VoidCallback onClose; - final Future Function(int, BaseLayout) renderFigure; - - const _FigurePopupOverlay({ - required this.figure, - required this.figurePage, - required this.targetPosition, - required this.onClose, - required this.renderFigure, - }); - - @override - State<_FigurePopupOverlay> createState() => _FigurePopupOverlayState(); -} - -class _FigurePopupOverlayState extends State<_FigurePopupOverlay> - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _scaleAnimation; - late Animation _fadeAnimation; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 200), - vsync: this, - ); - - _scaleAnimation = Tween(begin: 0.7, end: 1.0).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeOutBack), - ); - - _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeOut), - ); - - _animationController.forward(); - } - - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - - Size _calculatePopupSize(Size screenSize, BaseLayout figure) { - // Get figure dimensions from the rect - final figureRect = figure.rect; - final figureWidth = figureRect.width; - final figureHeight = figureRect.height; - final aspectRatio = figureWidth / figureHeight; - - // Define constraints - const minPopupWidth = 300.0; - const minPopupHeight = 200.0; - const headerHeight = 64.0; // Approximate header height - const padding = 32.0; // Total padding around content - const margin = 40.0; // Margin from screen edges - - // Calculate maximum available space - final maxPopupWidth = screenSize.width - (margin * 2); - final maxPopupHeight = screenSize.height - (margin * 2); - final maxContentHeight = maxPopupHeight - headerHeight - padding; - - // Calculate optimal size based on aspect ratio - double popupWidth; - double popupHeight; - - if (aspectRatio > 1) { - // Landscape figure - prioritize width - popupWidth = (figureWidth * 0.8).clamp(minPopupWidth, maxPopupWidth); - final contentHeight = (popupWidth - padding) / aspectRatio; - popupHeight = (contentHeight + headerHeight + padding).clamp( - minPopupHeight, - maxPopupHeight, - ); - - // Adjust width if height constraint is hit - if (popupHeight == maxPopupHeight) { - final adjustedContentHeight = maxContentHeight; - popupWidth = (adjustedContentHeight * aspectRatio + padding).clamp( - minPopupWidth, - maxPopupWidth, - ); - } - } else { - // Portrait or square figure - prioritize height - final contentHeight = (figureHeight * 0.8).clamp( - minPopupHeight - headerHeight - padding, - maxContentHeight, - ); - popupHeight = contentHeight + headerHeight + padding; - popupWidth = ((contentHeight * aspectRatio) + padding).clamp( - minPopupWidth, - maxPopupWidth, - ); - } - - return Size(popupWidth, popupHeight); - } - - @override - Widget build(BuildContext context) { - final screenSize = MediaQuery.of(context).size; - final theme = Theme.of(context); - - // Calculate popup size based on figure dimensions - final popupSize = _calculatePopupSize(screenSize, widget.figure); - final popupWidth = popupSize.width; - final popupHeight = popupSize.height; - const pinHeight = 20.0; - const margin = 20.0; - - // Determine if popup should appear above or below the target - final showAbove = widget.targetPosition.dy > screenSize.height / 2; - - // Calculate popup position - double popupLeft = widget.targetPosition.dx - popupWidth / 2; - double popupTop = - showAbove - ? widget.targetPosition.dy - popupHeight - pinHeight - margin - : widget.targetPosition.dy + margin; - - // Ensure popup stays within screen bounds - popupLeft = popupLeft.clamp(margin, screenSize.width - popupWidth - margin); - - // For vertical positioning, ensure popup fits on screen - final totalPopupHeight = popupHeight + pinHeight; - if (showAbove) { - // Check if there's enough space above - final minTopForAbove = margin; - final maxTopForAbove = - widget.targetPosition.dy - totalPopupHeight - margin; - - if (maxTopForAbove < minTopForAbove) { - // Not enough space above, switch to below - popupTop = widget.targetPosition.dy + margin; - } else { - popupTop = maxTopForAbove; - } - } else { - // Check if there's enough space below - final maxTopForBelow = screenSize.height - totalPopupHeight - margin; - - if (widget.targetPosition.dy + margin > maxTopForBelow) { - // Not enough space below, switch to above or limit height - popupTop = widget.targetPosition.dy - totalPopupHeight - margin; - if (popupTop < margin) { - // Force to fit on screen by positioning at top margin - popupTop = margin; - } - } else { - popupTop = widget.targetPosition.dy + margin; - } - } - - // Final clamp to ensure popup stays within screen bounds - popupTop = popupTop.clamp( - margin, - screenSize.height - totalPopupHeight - margin, - ); - - // Calculate pin position relative to popup - final pinLeft = - widget.targetPosition.dx - popupLeft - 10; // 10 is half of pin width - - return AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Stack( - children: [ - // Dimmed background - Positioned.fill( - child: GestureDetector( - onTap: () { - _animationController.reverse().then((_) => widget.onClose()); - }, - child: Container( - color: Colors.black.withOpacity(0.3 * _fadeAnimation.value), - ), - ), - ), - // Popup with pin - Positioned( - left: popupLeft, - top: popupTop, - child: Transform.scale( - scale: _scaleAnimation.value, - alignment: - showAbove ? Alignment.bottomCenter : Alignment.topCenter, - child: Opacity( - opacity: _fadeAnimation.value, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!showAbove) _buildPin(theme, pinLeft, popupWidth), - Container( - width: popupWidth, - height: popupHeight, - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 20, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(12), - ), - ), - child: Row( - children: [ - Expanded( - child: Text( - widget.figure.caption ?? - 'Figure ${widget.figure.figureNumber}', - style: theme.textTheme.titleMedium, - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _animationController.reverse().then( - (_) => widget.onClose(), - ); - }, - ), - ], - ), - ), - // Figure content - Expanded( - child: Padding( - padding: const EdgeInsets.all(16), - child: FutureBuilder( - future: widget.renderFigure( - widget.figurePage.pageIndex, - widget.figure, - ), - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator(), - ); - } - if (snapshot.hasError) { - return Center( - child: Text('Error: ${snapshot.error}'), - ); - } - return snapshot.data ?? - const Text('Figure not available'); - }, - ), - ), - ), - ], - ), - ), - if (showAbove) _buildPin(theme, pinLeft, popupWidth), - ], - ), - ), - ), - ), - ], + // 하이라이트 위젯을 별도 메서드로 분리 + Widget _buildHighlightWidget(BuildContext context, BaseLayout reference) { + return Builder( + builder: (innerContext) { + return FigureHighlightWidget( + layout: reference, + onFigureReferenceTap: () { + // Get the global position of the tap + final RenderBox? renderBox = + innerContext.findRenderObject() as RenderBox?; + if (renderBox != null) { + final globalPosition = renderBox.localToGlobal(Offset.zero); + _showFigurePopover(reference, globalPosition); + } + }, + onAskAI: () { + print('Ask AI for figure: ${reference.content}'); + _showAIChatPopup(reference); + }, ); }, ); } - - Widget _buildPin(ThemeData theme, double pinLeft, double popupWidth) { - return SizedBox( - width: popupWidth, - height: 20, - child: CustomPaint( - painter: _PinPainter( - color: theme.colorScheme.surface, - shadowColor: Colors.black.withOpacity(0.2), - pinPosition: pinLeft, - ), - ), - ); - } -} - -// Custom painter for the pin/arrow -class _PinPainter extends CustomPainter { - final Color color; - final Color shadowColor; - final double pinPosition; - - _PinPainter({ - required this.color, - required this.shadowColor, - required this.pinPosition, - }); - - @override - void paint(Canvas canvas, Size size) { - final paint = - Paint() - ..color = color - ..style = PaintingStyle.fill; - - final shadowPaint = - Paint() - ..color = shadowColor - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); - - // Create pin path - final path = Path(); - const pinWidth = 20.0; - const pinHeight = 20.0; - - // Clamp pin position to ensure it stays within bounds - final clampedPosition = pinPosition.clamp( - 10.0, - size.width - pinWidth - 10.0, - ); - - path.moveTo(clampedPosition, 0); - path.lineTo(clampedPosition + pinWidth, 0); - path.lineTo(clampedPosition + pinWidth / 2, pinHeight); - path.close(); - - // Draw shadow - canvas.drawPath(path, shadowPaint); - // Draw pin - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } diff --git a/lib/features/pdf_viewer/screens/pdf_viewer_screen.dart b/lib/features/pdf_viewer/screens/pdf_viewer_screen.dart deleted file mode 100644 index 3723108..0000000 --- a/lib/features/pdf_viewer/screens/pdf_viewer_screen.dart +++ /dev/null @@ -1,385 +0,0 @@ -// lib/features/pdf_viewer/screens/pdf_viewer_screen.dart - -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:path/path.dart' as p; -import 'package:pdfrx/pdfrx.dart'; - -import 'package:snapfig/shared/services/pdf_service.dart'; -import 'package:snapfig/features/pdf_viewer/models/pdf_document_model.dart'; -import 'package:snapfig/features/pdf_viewer/models/pdf_page_model.dart'; -import 'package:snapfig/features/pdf_viewer/models/pdf_layout_model.dart'; -import 'package:snapfig/features/pdf_viewer/widgets/pdf_page_viewer.dart'; - -/// 이제 경로(path)와 isAsset 정보를 생성자로 받습니다. -class PdfViewerScreen extends StatefulWidget { - /// PDF 파일의 경로. 에셋이면 "assets/…" 형태, 로컬 파일이면 절대경로. - final String path; - - /// [path]가 애셋인지(true: 에셋, false: 로컬 파일) 구분합니다. - final bool isAsset; - - const PdfViewerScreen({super.key, required this.path, this.isAsset = true}); - - @override - State createState() => _PdfViewerScreenState(); -} - -class _PdfViewerScreenState extends State - with SingleTickerProviderStateMixin { - late Future _pdfFuture; - late TabController _tabController; - - final GlobalKey _viewerKey = - GlobalKey(); - List _recentPaths = []; - List _figures = []; - String _searchQuery = ''; - bool _sidebarVisible = true; - - double _zoomPercent = 100; - int _currentPage = 1; - String _pdfTitle = 'PDF Viewer'; - - List _pageList = []; - late List _pageKeys; - - @override - void initState() { - super.initState(); - - // 탭 컨트롤러 초기화 - _tabController = TabController(length: 2, vsync: this, initialIndex: 0) - ..addListener(() => setState(() {})); - - // 생성자로 받은 경로/타입으로 PDF 로드 - _loadPdf(path: widget.path, isAsset: widget.isAsset); - } - - Future _loadPdf({required String path, required bool isAsset}) async { - // 1) 문서 로드 - final doc = - isAsset - ? await PdfService.instance.loadPdfFromAsset(path) - : await PdfService.instance.loadPdfFromFile(File(path)); - - // 2) 모델 변환 - final model = await PdfDocumentModel.fromDocument( - doc, - name: p.basename(path), - filePath: path, - ); - - // 3) 최근 목록 - final recents = await PdfService.instance.getRecentPaths(); - - // 4) 페이지 리스트 & 키 초기화 - final basePages = await model.getPages(); - final allPages = basePages.cast(); - // index 0는 무시 - final pageList = allPages.where((p) => p.pageIndex > 0).toList(); - final keys = List.generate(pageList.length, (_) => GlobalKey()); - - // 5) Figure(레이아웃) 리스트 - final layoutsPerPage = await Future.wait( - allPages.map((p) => p.getLayouts()), - ); - final allLayouts = - layoutsPerPage.expand((l) => l).whereType().toList(); - - // 6) 상태 반영 - setState(() { - _pdfFuture = Future.value(doc); - _recentPaths = recents; - _figures = allLayouts; - _currentPage = 1; - _zoomPercent = 100; - _pdfTitle = model.name; - - _pageList = pageList; - _pageKeys = keys; - }); - } - - /// 기기 로컬에서 PDF 선택 - Future _pickPdfFromDevice() async { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['pdf'], - ); - if (result != null && result.files.single.path != null) { - await _loadPdf(path: result.files.single.path!, isAsset: false); - } - } - - /// 최근 열어본 PDF 리스트 표시 - void _showRecentList() { - showModalBottomSheet( - context: context, - builder: - (_) => ListView.separated( - itemCount: _recentPaths.length, - separatorBuilder: (_, __) => const Divider(), - itemBuilder: (_, idx) { - final path = _recentPaths[idx]; - return ListTile( - leading: const Icon(Icons.picture_as_pdf), - title: Text(p.basename(path)), - subtitle: Text(path, overflow: TextOverflow.ellipsis), - onTap: () { - Navigator.pop(context); - _loadPdf(path: path, isAsset: false); - }, - ); - }, - ), - ); - } - - /// 페이지 변경 시 하단 숫자 & 사이드바 위치 업데이트 - void _onPageChanged(int pageIndex) { - setState(() => _currentPage = pageIndex); - final pos = _pageList.indexWhere((p) => p.pageIndex == pageIndex); - if (pos != -1 && _pageKeys[pos].currentContext != null) { - Scrollable.ensureVisible( - _pageKeys[pos].currentContext!, - duration: const Duration(milliseconds: 300), - alignment: 0.5, - ); - } - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final filteredPages = - _pageList - .where((p) => p.pageIndex.toString().contains(_searchQuery)) - .toList(); - final filteredFigures = - _figures - .where( - (f) => - f.content.toLowerCase().contains(_searchQuery.toLowerCase()), - ) - .toList(); - - return Scaffold( - appBar: AppBar( - title: Text(_pdfTitle), - actions: [ - IconButton( - icon: const Icon(Icons.history), - onPressed: _showRecentList, - ), - IconButton( - icon: const Icon(Icons.folder_open), - onPressed: _pickPdfFromDevice, - ), - ], - ), - body: FutureBuilder( - future: _pdfFuture, - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return Center(child: Text('Error: ${snap.error}')); - } - final doc = snap.data!; - return Row( - children: [ - // PDF 뷰어 - Expanded( - flex: 3, - child: Column( - children: [ - Expanded( - child: PdfPageViewer( - key: _viewerKey, - document: doc, - onScaleChanged: - (scale) => setState( - () => - _zoomPercent = (scale * 100).roundToDouble(), - ), - onPageChanged: _onPageChanged, - ), - ), - // 하단바 - Container( - height: 48, - color: - Theme.of(context).colorScheme.surfaceContainerHighest, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${_zoomPercent.toInt()}%', - style: Theme.of(context).textTheme.bodyMedium, - ), - Text( - '$_currentPage / ${_pageList.length}', - style: Theme.of(context).textTheme.bodyLarge, - ), - IconButton( - icon: const Icon(Icons.search), - tooltip: - _sidebarVisible - ? 'Hide sidebar' - : 'Show sidebar', - onPressed: () { - setState( - () => _sidebarVisible = !_sidebarVisible, - ); - }, - ), - ], - ), - ), - ], - ), - ), - - // 사이드바 - if (_sidebarVisible) - Container( - width: 260, - decoration: BoxDecoration( - border: Border( - left: BorderSide(color: Theme.of(context).dividerColor), - ), - ), - child: Column( - children: [ - // 탭 토글 - Padding( - padding: const EdgeInsets.all(8.0), - child: ToggleButtons( - isSelected: [ - _tabController.index == 0, - _tabController.index == 1, - ], - onPressed: (i) => _tabController.animateTo(i), - borderRadius: BorderRadius.circular(20), - selectedBorderColor: - Theme.of(context).colorScheme.primary, - fillColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - selectedColor: Theme.of(context).colorScheme.primary, - color: Colors.grey, - constraints: const BoxConstraints( - minWidth: 100, - minHeight: 36, - ), - children: const [Text('Page'), Text('Figure')], - ), - ), - - // 리스트 - Expanded( - child: - _tabController.index == 0 - // Page 목록 - ? ListView.builder( - itemCount: filteredPages.length, - itemBuilder: (_, idx) { - final page = filteredPages[idx]; - final originalIdx = _pageList.indexWhere( - (p) => p.pageIndex == page.pageIndex, - ); - return Container( - key: _pageKeys[originalIdx], - child: ListTile( - leading: page.thumbnailWidget( - width: 60, - height: 80, - ), - title: Text('Page ${page.pageIndex}'), - trailing: const Icon( - Icons.chevron_right, - ), - onTap: () { - _viewerKey.currentState?.jumpToPage( - page.pageIndex, - ); - _onPageChanged(page.pageIndex); - }, - ), - ); - }, - ) - // Figure 목록 - : ListView.builder( - itemCount: filteredFigures.length, - itemBuilder: (_, idx) { - final fig = filteredFigures[idx]; - return ListTile( - leading: fig.thumbnailWidget( - width: 60, - height: 60, - ), - title: Text( - fig.content, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: const Icon(Icons.chevron_right), - onTap: - () => _viewerKey.currentState - ?.jumpToPage(fig.pageIndex), - ); - }, - ), - ), - - // 검색바 - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - decoration: InputDecoration( - hintText: - _tabController.index == 0 - ? 'Search Page' - : 'Search Figure', - prefixIcon: const Icon(Icons.search), - suffixIcon: - _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: - () => - setState(() => _searchQuery = ''), - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - isDense: true, - contentPadding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 12, - ), - ), - onChanged: (v) => setState(() => _searchQuery = v), - ), - ), - ], - ), - ), - ], - ); - }, - ), - ); - } -} diff --git a/lib/features/pdf_viewer/widgets/figure_highlight_widget.dart b/lib/features/pdf_viewer/widgets/figure_highlight_widget.dart new file mode 100644 index 0000000..eec932b --- /dev/null +++ b/lib/features/pdf_viewer/widgets/figure_highlight_widget.dart @@ -0,0 +1,381 @@ +import 'package:flutter/material.dart'; +import 'package:snapfig/shared/services/pdf_core/models/models.dart'; + +class FigureHighlightWidget extends StatefulWidget { + final BaseLayout layout; + final VoidCallback? onFigureReferenceTap; + final VoidCallback? onAskAI; + + const FigureHighlightWidget({ + super.key, + required this.layout, + this.onFigureReferenceTap, + this.onAskAI, + }); + + @override + State createState() => _FigureHighlightWidgetState(); +} + +class _FigureHighlightWidgetState extends State + with TickerProviderStateMixin { + late AnimationController _animationController; + late Animation _borderAnimation; + late Animation _buttonAnimation; + + bool _isActivated = false; + OverlayEntry? _askAIOverlay; + + @override + void initState() { + super.initState(); + + _animationController = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + + _borderAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic), + ); + + _buttonAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _animationController, + curve: const Interval(0.3, 1.0, curve: Curves.elasticOut), + ), + ); + } + + @override + void dispose() { + _dismissAskAIOverlay(); + _animationController.dispose(); + super.dispose(); + } + + bool get isFigure => widget.layout.type == LayoutType.figure; + bool get isFigureReference => + widget.layout.type == LayoutType.figureReference; + + void _onTap() { + if (isFigure) { + // For figures, show Circle to Search effect + print( + 'Figure tapped! isFigure: $isFigure, onAskAI: ${widget.onAskAI != null}', + ); + if (!_isActivated) { + setState(() { + _isActivated = true; + }); + _animationController.forward(); + + // Show Ask AI overlay after border animation starts + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted && _isActivated) { + _showAskAIOverlay(); + } + }); + + // Auto dismiss after 5 seconds (increased for testing) + Future.delayed(const Duration(seconds: 5), () { + if (mounted && _isActivated) { + _dismiss(); + } + }); + } + } else if (isFigureReference) { + // For figure references, call the traditional handler + print('Figure reference tapped!'); + widget.onFigureReferenceTap?.call(); + } + } + + void _dismiss() { + if (_isActivated) { + _dismissAskAIOverlay(); + _animationController.reverse().then((_) { + if (mounted) { + setState(() { + _isActivated = false; + }); + } + }); + } + } + + void _showAskAIOverlay() { + if (_askAIOverlay != null) return; + + // Get the widget's position on screen + final RenderBox? renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final position = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + + // Calculate button position (top-right of the figure) + final buttonLeft = position.dx + size.width - 80; + final buttonTop = position.dy - 10; + + final overlay = Overlay.of(context); + + _askAIOverlay = OverlayEntry( + builder: (context) => Positioned( + left: buttonLeft, + top: buttonTop, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + child: AnimatedBuilder( + animation: _buttonAnimation, + builder: (context, child) { + final animationValue = _buttonAnimation.value.clamp(0.0, 1.0); + final scale = animationValue; + final opacity = animationValue; + + return Transform.scale( + scale: scale.clamp(0.0, 3.0), + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + print('AI button tapped!'); + if (widget.onAskAI != null) { + widget.onAskAI!.call(); + } else { + print('onAskAI is null!'); + } + _dismiss(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8.0, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.smart_toy, + color: Theme.of(context).colorScheme.onPrimary, + size: 16, + ), + const SizedBox(width: 6), + Text( + 'Ask AI', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + overlay.insert(_askAIOverlay!); + } + + void _dismissAskAIOverlay() { + _askAIOverlay?.remove(); + _askAIOverlay = null; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + print( + 'Building FigureHighlightWidget: isFigure=$isFigure, isActivated=$_isActivated, onAskAI=${widget.onAskAI != null}', + ); + + return GestureDetector( + onTap: _onTap, + child: Stack( + children: [ + // Base container + Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + // Figure references are visible, figures are transparent + color: + isFigureReference + ? theme.colorScheme.primary.withValues(alpha: 0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(2.0), + ), + ), + + // Animated border overlay for figures only + if (isFigure && _isActivated) + AnimatedBuilder( + animation: _borderAnimation, + builder: (context, child) { + final animationValue = _borderAnimation.value.clamp(0.0, 1.0); + final opacity = animationValue; + final scale = 0.95 + (animationValue * 0.05); + + return Transform.scale( + scale: scale.clamp(0.0, 2.0), + child: Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + border: Border.all( + color: theme.colorScheme.primary.withValues( + alpha: (opacity * 0.8).clamp(0.0, 1.0), + ), + width: 2.0, + ), + borderRadius: BorderRadius.circular(6.0), + color: theme.colorScheme.primary.withValues( + alpha: (opacity * 0.05).clamp(0.0, 1.0), + ), + boxShadow: [ + BoxShadow( + color: theme.colorScheme.primary.withValues( + alpha: (opacity * 0.2).clamp(0.0, 1.0), + ), + blurRadius: 8.0, + spreadRadius: 1.0, + ), + ], + ), + child: Stack( + children: [ + // Corner highlights (Gemini-style) + Positioned( + top: -1, + left: -1, + child: _buildCornerHighlight( + theme, + opacity, + true, + true, + ), + ), + Positioned( + top: -1, + right: -1, + child: _buildCornerHighlight( + theme, + opacity, + true, + false, + ), + ), + Positioned( + bottom: -1, + left: -1, + child: _buildCornerHighlight( + theme, + opacity, + false, + true, + ), + ), + Positioned( + bottom: -1, + right: -1, + child: _buildCornerHighlight( + theme, + opacity, + false, + false, + ), + ), + ], + ), + ), + ); + }, + ), + + + // Tap to dismiss overlay for figures + if (isFigure && _isActivated) + Positioned.fill( + child: GestureDetector( + onTap: _dismiss, + child: Container(color: Colors.transparent), + ), + ), + ], + ), + ); + } + + Widget _buildCornerHighlight( + ThemeData theme, + double opacity, + bool isTop, + bool isLeft, + ) { + return Container( + width: 12, + height: 12, + decoration: BoxDecoration( + border: Border( + top: + isTop + ? BorderSide( + color: theme.colorScheme.primary.withValues( + alpha: opacity.clamp(0.0, 1.0), + ), + width: 3.0, + ) + : BorderSide.none, + bottom: + !isTop + ? BorderSide( + color: theme.colorScheme.primary.withValues( + alpha: opacity.clamp(0.0, 1.0), + ), + width: 3.0, + ) + : BorderSide.none, + left: + isLeft + ? BorderSide( + color: theme.colorScheme.primary.withValues( + alpha: opacity.clamp(0.0, 1.0), + ), + width: 3.0, + ) + : BorderSide.none, + right: + !isLeft + ? BorderSide( + color: theme.colorScheme.primary.withValues( + alpha: opacity.clamp(0.0, 1.0), + ), + width: 3.0, + ) + : BorderSide.none, + ), + ), + ); + } +} diff --git a/lib/features/pdf_viewer/widgets/figure_list_panel.dart b/lib/features/pdf_viewer/widgets/figure_list_panel.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart new file mode 100644 index 0000000..b9f4fd2 --- /dev/null +++ b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:snapfig/features/pdf_viewer/models/pdf_data_viewmodel.dart'; +import 'package:snapfig/shared/services/pdf_core/models/models.dart'; + +class FigureOverlayWidget extends StatefulWidget { + final BaseLayout reference; + final PDFDataViewModel viewModel; + final VoidCallback onClose; + final void Function(BaseLayout figure)? navigateToFigure; + final void Function(BaseLayout figure)? onAskAI; + + const FigureOverlayWidget({ + super.key, + required this.reference, + required this.viewModel, + required this.onClose, + this.navigateToFigure, + this.onAskAI, + }); + + @override + State createState() => _FigureOverlayWidgetState(); +} + +class _FigureOverlayWidgetState extends State { + final TransformationController _transformationController = TransformationController(); + + @override + void dispose() { + _transformationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // Find the actual figure that this reference points to + final targetFigure = widget.viewModel.findFigureByReference(widget.reference); + if (targetFigure == null) return _buildNotFoundContent(context); + + return FutureBuilder( + future: widget.viewModel.getFigureImage(targetFigure), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _buildLoadingContent(context); + } else if (snapshot.hasData && snapshot.data != null) { + return Container( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Figure image with zoom controls + Container( + padding: const EdgeInsets.all(8), + child: LayoutBuilder( + builder: (context, constraints) { + final image = snapshot.data!; + final imageAspectRatio = image.width / image.height; + + // Dynamic sizing based on screen size and content + final screenSize = MediaQuery.of(context).size; + final availableWidth = constraints.maxWidth - 16; + + // Adaptive height based on screen size + final maxHeightRatio = screenSize.height > 800 ? 0.5 : 0.4; + final availableHeight = screenSize.height * maxHeightRatio; + + double displayWidth; + double displayHeight; + + final widthBasedHeight = availableWidth / imageAspectRatio; + final heightBasedWidth = availableHeight * imageAspectRatio; + + if (widthBasedHeight <= availableHeight) { + displayWidth = availableWidth; + displayHeight = widthBasedHeight; + } else { + displayWidth = heightBasedWidth; + displayHeight = availableHeight; + } + + // Dynamic minimum constraints based on screen size + final minWidth = screenSize.width * 0.2; + final minHeight = screenSize.height * 0.1; + + displayWidth = displayWidth.clamp(minWidth, availableWidth); + displayHeight = displayHeight.clamp(minHeight, availableHeight); + + return Container( + width: displayWidth, + height: displayHeight, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: InteractiveViewer( + transformationController: _transformationController, + minScale: 0.5, + maxScale: 4.0, + constrained: true, + child: RawImage( + image: image, + fit: BoxFit.contain, + alignment: Alignment.center, + ), + ), + ), + ); + }, + ), + ), + // Action button + Container( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.tonalIcon( + onPressed: () { + widget.navigateToFigure?.call(targetFigure); + }, + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Go to Figure'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: () { + widget.onAskAI?.call(targetFigure); + }, + icon: const Icon(Icons.smart_toy, size: 18), + label: const Text('Ask AI'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } else { + return _buildNotFoundContent(context); + } + }, + ); + } + + Widget _buildLoadingContent(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Loading figure...', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Widget _buildNotFoundContent(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.image_not_supported, + size: 48, + color: theme.colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + 'Figure not found', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + 'The referenced figure could not be located in the document.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: widget.onClose, + icon: const Icon(Icons.close, size: 16), + label: const Text('Close'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/pdf_viewer/widgets/ocr_overlay_painter.dart b/lib/features/pdf_viewer/widgets/ocr_overlay_painter.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/pdf_viewer/widgets/pdf_bottom_bar.dart b/lib/features/pdf_viewer/widgets/pdf_bottom_bar.dart index f1e0f32..aa1720e 100644 --- a/lib/features/pdf_viewer/widgets/pdf_bottom_bar.dart +++ b/lib/features/pdf_viewer/widgets/pdf_bottom_bar.dart @@ -28,7 +28,7 @@ class PDFBottomBar extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '$zoomPercent%', + '$zoomPercent%', style: Theme.of(context).textTheme.bodyMedium, ), Text( diff --git a/lib/features/pdf_viewer/widgets/pdf_page_viewer.dart b/lib/features/pdf_viewer/widgets/pdf_page_viewer.dart deleted file mode 100644 index f0f182c..0000000 --- a/lib/features/pdf_viewer/widgets/pdf_page_viewer.dart +++ /dev/null @@ -1,175 +0,0 @@ -// lib/features/pdf_viewer/widgets/pdf_page_viewer.dart - -import 'package:flutter/material.dart'; -import 'package:pdfrx/pdfrx.dart'; -import 'package:vector_math/vector_math_64.dart' as vm; -import '../../../shared/services/pdf_core/pdf_core.dart'; - -/// PDF 문서를 화면에 렌더링하고 -/// - 수직 스크롤 -/// - 핀치 제스처 확대/축소 -/// - 외부에서 페이지 점프 기능 제공 -/// - 확대율 및 페이지 변경 콜백 -class PdfPageViewer extends StatefulWidget { - final PdfDocument document; - final ValueChanged? onScaleChanged; - final ValueChanged? onPageChanged; - final List? pages; - - const PdfPageViewer({ - super.key, - required this.document, - this.onScaleChanged, - this.onPageChanged, - this.pages, - }); - - static PdfPageViewerState? of(BuildContext context) => - context.findAncestorStateOfType(); - - @override - PdfPageViewerState createState() => PdfPageViewerState(); -} - -class PdfPageViewerState extends State { - late final PageController _pageController; - late final TransformationController _transformationController; - late vm.Vector3 _lastTranslate; - double _currentScale = 1.0; - final double _minScale = 1.0; - final double _maxScale = 4.0; - - bool _fitWidth = false; - bool _fitHeight = false; - - @override - void initState() { - super.initState(); - - // 페이지 컨트롤러: 페이지 변화 콜백 등록 - _pageController = PageController(initialPage: 1) // 초기 페이지 값 설정 - ..addListener(() { - final page = (_pageController.page ?? 1).round(); // 페이지 목록의 첫번째 페이지 값 설정 - widget.onPageChanged?.call(page); - }); - - // TransformationController 생성 후 리스너 등록 - _transformationController = - TransformationController()..addListener(_onTransformChanged); - _lastTranslate = vm.Vector3.zero(); - } - - @override - void dispose() { - _pageController.dispose(); - _transformationController.removeListener(_onTransformChanged); - _transformationController.dispose(); - super.dispose(); - } - - /// 외부에서 호출할 수 있도록 페이지 점프 메서드 공개 - void jumpToPage(int pageIndex) { - _pageController.jumpToPage(pageIndex); - } - - /// TransformationController 에 변화가 생길 때마다 호출 - void _onTransformChanged() { - // 매트릭스에서 스케일 축 최대값을 추출 - final matrix = _transformationController.value; - final scale = matrix.getMaxScaleOnAxis(); - // 스케일이 변경됐다면 상태 갱신 및 콜백 - if (scale != _currentScale) { - _currentScale = scale.clamp(_minScale, _maxScale); - widget.onScaleChanged?.call(_currentScale); - } - } - - void _zoomIn() { - _lastTranslate = _transformationController.value.getTranslation(); - final newScale = (_currentScale + 0.5).clamp(_minScale, _maxScale); - setState(() { - _currentScale = newScale; - _transformationController.value = - Matrix4.identity() - ..translate(_lastTranslate.x, _lastTranslate.y) - ..scale(_currentScale); - widget.onScaleChanged?.call(_currentScale); - }); - } - - void _zoomOut() { - _lastTranslate = _transformationController.value.getTranslation(); - final newScale = (_currentScale - 0.5).clamp(_minScale, _maxScale); - setState(() { - _currentScale = newScale; - _transformationController.value = - Matrix4.identity() - ..translate(_lastTranslate.x, _lastTranslate.y) - ..scale(_currentScale); - widget.onScaleChanged?.call(_currentScale); - }); - } - - void _resetAlignment() { - setState(() { - _transformationController.value = - Matrix4.identity()..scale(_currentScale); - widget.onScaleChanged?.call(_currentScale); - }); - } - - void _toggleFitWidth() { - setState(() { - _fitWidth = true; - _fitHeight = false; - }); - } - - void _toggleFitHeight() { - setState(() { - _fitWidth = false; - _fitHeight = true; - }); - } - - @override - Widget build(BuildContext context) { - final pageCount = widget.document.pages.length; - - if (pageCount == 0) { - return const Center(child: Text('No pages found in document')); - } - - return PageView.builder( - controller: _pageController, - scrollDirection: Axis.vertical, - itemCount: pageCount, - itemBuilder: (context, index) { - if (index >= widget.document.pages.length) { - return const Center(child: Text('Page index out of bounds')); - } - return LayoutBuilder( - builder: (context, constraints) { - return InteractiveViewer( - transformationController: _transformationController, - panEnabled: true, - scaleEnabled: true, - minScale: _minScale, - maxScale: _maxScale, - child: SizedBox( - width: constraints.maxWidth, - height: constraints.maxHeight, - child: PdfPageView( - backgroundColor: - Theme.of(context).colorScheme.surfaceContainer, - document: widget.document, - pageNumber: index + 1, - ), - ), - ); - }, - ); - }, - ); - } -} diff --git a/lib/features/pdf_viewer/widgets/popover_wrapper.dart b/lib/features/pdf_viewer/widgets/popover_wrapper.dart new file mode 100644 index 0000000..230d510 --- /dev/null +++ b/lib/features/pdf_viewer/widgets/popover_wrapper.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; + +class PopoverWrapper extends StatelessWidget { + final Offset targetPosition; + final Widget child; + final VoidCallback? onDismiss; + final double? width; + final double? maxHeight; + + const PopoverWrapper({ + super.key, + required this.targetPosition, + required this.child, + this.onDismiss, + this.width, + this.maxHeight, + }); + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + final theme = Theme.of(context); + + // Dynamic popover sizing based on screen size and parameters + final popoverWidth = width ?? (screenSize.width > 600 ? 350.0 : screenSize.width * 0.8); + final popoverMaxHeight = maxHeight ?? (screenSize.height > 800 ? 500.0 : screenSize.height * 0.6); + const arrowSize = 10.0; + const margin = 20.0; + + // Determine if popover should appear above or below the target + final spaceAbove = targetPosition.dy - margin; + final spaceBelow = screenSize.height - targetPosition.dy - margin; + final showAbove = spaceAbove > spaceBelow && spaceAbove > popoverMaxHeight; + + // Calculate horizontal position (center on target, but keep within screen bounds) + double left = targetPosition.dx - popoverWidth / 2; + if (left < margin) left = margin; + if (left + popoverWidth > screenSize.width - margin) { + left = screenSize.width - popoverWidth - margin; + } + + // Calculate vertical position with better spacing + final top = + showAbove + ? targetPosition.dy - popoverMaxHeight - arrowSize - 8 + : targetPosition.dy + arrowSize + 8; + + // Calculate arrow position relative to popover (clamped to ensure it's visible) + final arrowLeft = (targetPosition.dx - left).clamp( + arrowSize * 2, + popoverWidth - arrowSize * 2, + ); + + return Material( + color: Colors.transparent, + child: GestureDetector( + onTap: onDismiss, + child: Container( + color: Colors.transparent, + child: Stack( + children: [ + // Popover container + Positioned( + left: left, + top: top, + child: GestureDetector( + onTap: () {}, // Prevent tap from bubbling up + child: Container( + width: popoverWidth, + constraints: BoxConstraints( + maxHeight: popoverMaxHeight, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + width: 0.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 30, + offset: const Offset(0, 12), + spreadRadius: 0, + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 10, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: child, + ), + ), + ), + ), + // Arrow + Positioned( + left: left + arrowLeft - arrowSize, + top: showAbove ? top + popoverMaxHeight : top - arrowSize, + child: CustomPaint( + size: Size(arrowSize * 2, arrowSize), + painter: _ArrowPainter( + color: theme.colorScheme.surface, + borderColor: theme.colorScheme.outline.withValues( + alpha: 0.2, + ), + pointsUp: !showAbove, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ArrowPainter extends CustomPainter { + final Color color; + final Color borderColor; + final bool pointsUp; + + _ArrowPainter({ + required this.color, + required this.borderColor, + required this.pointsUp, + }); + + @override + void paint(Canvas canvas, Size size) { + final fillPaint = + Paint() + ..color = color + ..style = PaintingStyle.fill; + + final borderPaint = + Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 0.5; + + final path = Path(); + + if (pointsUp) { + path.moveTo(size.width / 2, 0); + path.lineTo(0, size.height); + path.lineTo(size.width, size.height); + } else { + path.moveTo(0, 0); + path.lineTo(size.width, 0); + path.lineTo(size.width / 2, size.height); + } + path.close(); + + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, borderPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart b/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart new file mode 100644 index 0000000..d42eac4 --- /dev/null +++ b/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:snapfig/shared/services/pdf_core/pdf_core.dart'; + +class FigureSidebar extends StatelessWidget { + final List figures; + final Function(BaseLayout) onFigureSelected; + + const FigureSidebar({ + super.key, + required this.figures, + required this.onFigureSelected, + }); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: figures.length, + itemBuilder: (context, index) { + return ListTile( + leading: _buildThumbnail(figures[index], context: context), + title: Text(figures[index].figureId ?? ''), + trailing: const Icon(Icons.chevron_right), + onTap: () => onFigureSelected(figures[index]), + ); + }, + ); + } + + Widget _buildThumbnail(BaseLayout figures, {required BuildContext context}) { + final theme = Theme.of(context); + return Container( + width: 45, + height: 45, + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(15), + ), + child: Icon( + Icons.picture_as_pdf, + color: theme.colorScheme.onSecondaryContainer, + ), + ); + } +} diff --git a/lib/features/pdf_viewer/widgets/sidebar/outline_sidebar.dart b/lib/features/pdf_viewer/widgets/sidebar/outline_sidebar.dart new file mode 100644 index 0000000..93223dc --- /dev/null +++ b/lib/features/pdf_viewer/widgets/sidebar/outline_sidebar.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:pdfrx/pdfrx.dart'; + +class OutlineSidebar extends StatelessWidget { + final List outlines; + final Function(PdfOutlineNode) onOutlineSelected; + + const OutlineSidebar({ + super.key, + required this.outlines, + required this.onOutlineSelected, + }); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: outlines.length, + itemBuilder: (context, index) { + return ListTile( + leading: _buildThumbnail(outlines[index], context: context), + title: Text(outlines[index].title), + trailing: const Icon(Icons.chevron_right), + onTap: () => onOutlineSelected(outlines[index]), + ); + }, + ); + } + + Widget _buildThumbnail( + PdfOutlineNode outline, { + required BuildContext context, + }) { + final theme = Theme.of(context); + return Container( + width: 45, + height: 45, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(15), + ), + child: Icon( + Icons.picture_as_pdf, + color: theme.colorScheme.onSurfaceVariant, + ), + ); + } +} diff --git a/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart b/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart new file mode 100644 index 0000000..e79aae7 --- /dev/null +++ b/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:pdfrx/pdfrx.dart'; +import 'package:snapfig/features/pdf_viewer/models/pdf_data_viewmodel.dart'; +import 'package:snapfig/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart'; +import 'package:snapfig/features/pdf_viewer/widgets/sidebar/outline_sidebar.dart'; +import 'package:snapfig/shared/services/pdf_core/pdf_core.dart'; + +class PDFSideBar extends StatefulWidget { + final PDFDataViewModel viewModel; + final Function(PdfOutlineNode) onPageSelected; + final Function(BaseLayout) onFigureSelected; + + const PDFSideBar({ + super.key, + required this.viewModel, + required this.onPageSelected, + required this.onFigureSelected, + }); + + @override + State createState() => _PDFSideBarState(); +} + +enum _PDFSideBarTab { page, figure } + +class _PDFSideBarState extends State { + final double _sidebarWidth = 300; + _PDFSideBarTab _selectedTab = _PDFSideBarTab.page; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: _sidebarWidth, + color: Theme.of(context).colorScheme.surfaceVariant, + child: Column( + children: [ + SegmentedButton<_PDFSideBarTab>( + showSelectedIcon: true, + segments: const [ + ButtonSegment(value: _PDFSideBarTab.page, label: Text('Outline')), + ButtonSegment( + value: _PDFSideBarTab.figure, + label: Text('Figure'), + ), + ], + selected: {_selectedTab}, + onSelectionChanged: (value) { + setState(() { + _selectedTab = value.first; + }); + }, + ), + if (_selectedTab == _PDFSideBarTab.page) + Expanded( + child: OutlineSidebar( + outlines: widget.viewModel.outlines, + onOutlineSelected: widget.onPageSelected, + ), + ), + if (_selectedTab == _PDFSideBarTab.figure) + Expanded( + child: FigureSidebar( + figures: widget.viewModel.figures, + onFigureSelected: widget.onFigureSelected, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/pdf_viewer/widgets/simple_draggable_ai_chat.dart b/lib/features/pdf_viewer/widgets/simple_draggable_ai_chat.dart new file mode 100644 index 0000000..49cbd01 --- /dev/null +++ b/lib/features/pdf_viewer/widgets/simple_draggable_ai_chat.dart @@ -0,0 +1,754 @@ +import 'package:flutter/material.dart'; +import 'package:snapfig/shared/services/ai_service/ai_service.dart'; +import 'package:snapfig/shared/services/ai_service/models/ai_provider.dart'; +import 'package:snapfig/shared/services/pdf_core/models/models.dart'; +import 'package:snapfig/features/pdf_viewer/models/pdf_data_viewmodel.dart'; + +class Message { + final String content; + final bool isUser; + final DateTime timestamp; + final bool isLoading; + + Message({ + required this.content, + required this.isUser, + DateTime? timestamp, + this.isLoading = false, + }) : timestamp = timestamp ?? DateTime.now(); +} + +class SimpleDraggableAIChat extends StatefulWidget { + final BaseLayout figure; + final PDFDataViewModel viewModel; + final VoidCallback onClose; + final Offset? initialPosition; + + const SimpleDraggableAIChat({ + super.key, + required this.figure, + required this.viewModel, + required this.onClose, + this.initialPosition, + }); + + @override + State createState() => _SimpleDraggableAIChatState(); +} + +class _SimpleDraggableAIChatState extends State + with SingleTickerProviderStateMixin { + final List _messages = []; + final TextEditingController _textController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + AIProvider? _selectedProvider; + bool _isLoading = false; + + // Position and animation + Offset _position = const Offset(100, 100); + late AnimationController _animationController; + late Animation _scaleAnimation; + bool _isDragging = false; + + static const double _chatWidth = 320.0; + static const double _chatHeight = 400.0; + + @override + void initState() { + super.initState(); + _initializePosition(); + _initializeAnimations(); + _loadAvailableProviders(); + _addWelcomeMessage(); + _sendInitialFigureMessage(); + } + + void _initializePosition() { + if (widget.initialPosition != null) { + _position = widget.initialPosition!; + } else { + // Default to center of screen + WidgetsBinding.instance.addPostFrameCallback((_) { + final screenSize = MediaQuery.of(context).size; + setState(() { + _position = Offset( + (screenSize.width - _chatWidth) / 2, // Center horizontally + (screenSize.height - _chatHeight) / 2, // Center vertically + ); + }); + }); + } + } + + void _initializeAnimations() { + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _scaleAnimation = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOutBack), + ); + + _animationController.forward(); + } + + void _loadAvailableProviders() { + final configurations = AIService.instance.configurations; + if (configurations.isNotEmpty) { + _selectedProvider = configurations.first.provider; + } + } + + void _addWelcomeMessage() { + _messages.add( + Message( + content: + 'Hello! I can help you understand this figure. What would you like to know about it?', + isUser: false, + ), + ); + } + + Future _sendInitialFigureMessage() async { + if (_selectedProvider == null) return; + + // Add user message indicating we're sending the figure + setState(() { + _messages.add( + Message(content: 'Here is the figure I want to analyze', isUser: true), + ); + _messages.add(Message(content: '', isUser: false, isLoading: true)); + _isLoading = true; + }); + + _scrollToBottom(); + + try { + // Get the figure image + final figureImage = await widget.viewModel.getFigureImage(widget.figure); + if (figureImage == null) { + throw Exception('Could not load figure image'); + } + + final pageNumber = widget.viewModel.getPageNumberForFigure(widget.figure); + final context = + 'Figure ID: ${widget.figure.figureId ?? widget.figure.id.toString()}, Page: ${pageNumber ?? 'Unknown'}'; + + final response = await AIService.instance.askAI( + provider: _selectedProvider!, + question: + 'Please analyze this figure and describe what you can see. What kind of data or information does it represent?', + context: context, + image: figureImage, + ); + + setState(() { + _messages.removeLast(); // Remove loading message + _messages.add(Message(content: response, isUser: false)); + _isLoading = false; + }); + } catch (e) { + setState(() { + _messages.removeLast(); // Remove loading message + _messages.add( + Message( + content: 'Sorry, I couldn\'t analyze the figure: ${e.toString()}', + isUser: false, + ), + ); + _isLoading = false; + }); + } + + _scrollToBottom(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final configurations = AIService.instance.configurations; + final screenSize = MediaQuery.of(context).size; + + if (configurations.isEmpty) { + return _buildNoConfigurationView(context, screenSize); + } + + // Constrain position to screen bounds + final constrainedPosition = _constrainPosition(_position, screenSize); + + return Positioned( + left: constrainedPosition.dx, + top: constrainedPosition.dy, + child: ScaleTransition( + scale: _scaleAnimation, + child: Material( + elevation: _isDragging ? 16 : 8, + borderRadius: BorderRadius.circular(16), + child: GestureDetector( + onPanStart: (details) { + setState(() { + _isDragging = true; + }); + }, + onPanUpdate: (details) { + setState(() { + _position += details.delta; + }); + }, + onPanEnd: (details) { + setState(() { + _isDragging = false; + // Snap to screen edges if close enough + _position = _snapToEdges(_position, screenSize); + }); + }, + child: Container( + width: _chatWidth, + height: _chatHeight, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + _isDragging + ? theme.colorScheme.primary.withValues(alpha: 0.5) + : theme.colorScheme.outline.withValues(alpha: 0.2), + width: _isDragging ? 2 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues( + alpha: _isDragging ? 0.3 : 0.15, + ), + blurRadius: _isDragging ? 20 : 10, + offset: Offset(0, _isDragging ? 8 : 4), + ), + ], + ), + child: Column( + children: [ + _buildDraggableHeader(context), + Expanded(child: _buildMessageList(context)), + _buildInputSection(context), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildDraggableHeader(BuildContext context) { + final theme = Theme.of(context); + final configurations = AIService.instance.configurations; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: + _isDragging + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Column( + children: [ + // Drag handle + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Row( + children: [ + Icon( + Icons.smart_toy, + color: + _isDragging + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'AI Assistant', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: + _isDragging + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurface, + ), + ), + const Spacer(), + if (configurations.length > 1) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(8), + ), + child: DropdownButton( + value: _selectedProvider, + underline: const SizedBox(), + isDense: true, + items: + configurations.map((config) { + return DropdownMenuItem( + value: config.provider, + child: Text( + config.provider.displayName, + style: theme.textTheme.bodySmall, + ), + ); + }).toList(), + onChanged: (provider) { + setState(() { + _selectedProvider = provider; + }); + }, + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: _closeWithAnimation, + icon: Icon( + Icons.close, + size: 18, + color: + _isDragging + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurface, + ), + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4), + minimumSize: const Size(24, 24), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildMessageList(BuildContext context) { + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + return _buildMessageBubble(context, message); + }, + ); + } + + Widget _buildMessageBubble(BuildContext context, Message message) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: + message.isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!message.isUser) ...[ + CircleAvatar( + radius: 12, + backgroundColor: theme.colorScheme.primary, + child: Icon( + Icons.smart_toy, + size: 14, + color: theme.colorScheme.onPrimary, + ), + ), + const SizedBox(width: 8), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: + message.isUser + ? theme.colorScheme.primary + : theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(18), + ), + child: + message.isLoading + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Text( + 'Thinking...', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ) + : Text( + message.content, + style: theme.textTheme.bodyMedium?.copyWith( + color: + message.isUser + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + ), + ), + ), + if (message.isUser) ...[ + const SizedBox(width: 8), + CircleAvatar( + radius: 12, + backgroundColor: theme.colorScheme.secondary, + child: Icon( + Icons.person, + size: 14, + color: theme.colorScheme.onSecondary, + ), + ), + ], + ], + ), + ); + } + + Widget _buildInputSection(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + decoration: InputDecoration( + hintText: 'Ask about this figure...', + hintStyle: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: theme.colorScheme.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + maxLines: null, + textInputAction: TextInputAction.send, + onSubmitted: _isLoading ? null : (_) => _sendMessage(), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: + _isLoading || _textController.text.trim().isEmpty + ? null + : _sendMessage, + icon: Icon( + Icons.send, + color: + _isLoading || _textController.text.trim().isEmpty + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.primary, + ), + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.surfaceContainerHighest, + shape: const CircleBorder(), + ), + ), + ], + ), + ); + } + + Widget _buildNoConfigurationView(BuildContext context, Size screenSize) { + final theme = Theme.of(context); + final constrainedPosition = _constrainPosition(_position, screenSize); + + return Positioned( + left: constrainedPosition.dx, + top: constrainedPosition.dy, + child: ScaleTransition( + scale: _scaleAnimation, + child: Material( + elevation: _isDragging ? 16 : 8, + borderRadius: BorderRadius.circular(16), + child: GestureDetector( + onPanStart: (details) { + setState(() { + _isDragging = true; + }); + }, + onPanUpdate: (details) { + setState(() { + _position += details.delta; + }); + }, + onPanEnd: (details) { + setState(() { + _isDragging = false; + _position = _snapToEdges(_position, screenSize); + }); + }, + child: Container( + width: 300, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + _isDragging + ? theme.colorScheme.primary.withValues(alpha: 0.5) + : theme.colorScheme.outline.withValues(alpha: 0.2), + width: _isDragging ? 2 : 1, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + Row( + children: [ + Icon( + Icons.smart_toy, + color: theme.colorScheme.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'AI Assistant', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + onPressed: _closeWithAnimation, + icon: const Icon(Icons.close, size: 18), + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4), + minimumSize: const Size(24, 24), + ), + ), + ], + ), + const SizedBox(height: 16), + Icon( + Icons.settings, + size: 48, + color: theme.colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + 'AI Configuration Required', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Please configure your AI API keys to use this feature.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _closeWithAnimation, + child: const Text('Close'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: () { + Navigator.of(context).pushNamed('/ai-settings'); + }, + child: const Text('Settings'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + Offset _constrainPosition(Offset position, Size screenSize) { + // Use smaller width for no configuration view + final width = + AIService.instance.configurations.isEmpty ? 300.0 : _chatWidth; + final height = + AIService.instance.configurations.isEmpty ? 300.0 : _chatHeight; + + double constrainedX = position.dx.clamp(0.0, screenSize.width - width); + double constrainedY = position.dy.clamp(0.0, screenSize.height - height); + + return Offset(constrainedX, constrainedY); + } + + Offset _snapToEdges(Offset position, Size screenSize) { + const snapDistance = 20.0; + + // Use smaller width/height for no configuration view + final width = + AIService.instance.configurations.isEmpty ? 300.0 : _chatWidth; + final height = + AIService.instance.configurations.isEmpty ? 300.0 : _chatHeight; + + double x = position.dx; + double y = position.dy; + + // Snap to left edge + if (x < snapDistance) { + x = 0; + } + // Snap to right edge + else if (x > screenSize.width - width - snapDistance) { + x = screenSize.width - width; + } + + // Snap to top edge + if (y < snapDistance) { + y = 0; + } + // Snap to bottom edge + else if (y > screenSize.height - height - snapDistance) { + y = screenSize.height - height; + } + + return Offset(x, y); + } + + Future _sendMessage() async { + final text = _textController.text.trim(); + if (text.isEmpty || _selectedProvider == null) return; + + setState(() { + _messages.add(Message(content: text, isUser: true)); + _messages.add(Message(content: '', isUser: false, isLoading: true)); + _isLoading = true; + }); + + _textController.clear(); + _scrollToBottom(); + + try { + // Get the figure image for context + final figureImage = await widget.viewModel.getFigureImage(widget.figure); + + final pageNumber = widget.viewModel.getPageNumberForFigure(widget.figure); + final context = + 'Figure ID: ${widget.figure.figureId ?? widget.figure.id.toString()}, Page: ${pageNumber ?? 'Unknown'}'; + final response = await AIService.instance.askAI( + provider: _selectedProvider!, + question: text, + context: context, + image: figureImage, + ); + + setState(() { + _messages.removeLast(); // Remove loading message + _messages.add(Message(content: response, isUser: false)); + _isLoading = false; + }); + } catch (e) { + setState(() { + _messages.removeLast(); // Remove loading message + _messages.add( + Message( + content: 'Sorry, I encountered an error: ${e.toString()}', + isUser: false, + ), + ); + _isLoading = false; + }); + } + + _scrollToBottom(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + void _closeWithAnimation() { + _animationController.reverse().then((_) { + widget.onClose(); + }); + } + + @override + void dispose() { + _textController.dispose(); + _scrollController.dispose(); + _animationController.dispose(); + super.dispose(); + } +} diff --git a/lib/features/settings/ai_settings_screen.dart b/lib/features/settings/ai_settings_screen.dart new file mode 100644 index 0000000..d454ae3 --- /dev/null +++ b/lib/features/settings/ai_settings_screen.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:snapfig/shared/services/ai_service/ai_service.dart'; +import 'package:snapfig/shared/services/ai_service/models/ai_provider.dart'; + +class AISettingsScreen extends StatefulWidget { + const AISettingsScreen({super.key}); + + @override + State createState() => _AISettingsScreenState(); +} + +class _AISettingsScreenState extends State { + final Map _apiKeyControllers = {}; + final Map _modelControllers = {}; + final Map _obscureText = {}; + + @override + void initState() { + super.initState(); + _initializeControllers(); + _loadExistingConfigurations(); + } + + void _initializeControllers() { + for (final provider in AIProvider.values) { + _apiKeyControllers[provider] = TextEditingController(); + _modelControllers[provider] = TextEditingController(); + _obscureText[provider] = true; + } + } + + void _loadExistingConfigurations() { + for (final provider in AIProvider.values) { + final config = AIService.instance.getConfiguration(provider); + if (config != null) { + _apiKeyControllers[provider]!.text = config.apiKey; + _modelControllers[provider]!.text = config.model ?? ''; + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('AI Settings'), + backgroundColor: theme.colorScheme.surface, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Configure AI Providers', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Add your API keys to enable AI-powered figure analysis.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + Expanded( + child: ListView( + children: AIProvider.values.map((provider) { + return _buildProviderCard(context, provider); + }).toList(), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saveAllConfigurations, + child: const Text('Save Configuration'), + ), + ), + ], + ), + ), + ); + } + + Widget _buildProviderCard(BuildContext context, AIProvider provider) { + final theme = Theme.of(context); + final hasConfiguration = AIService.instance.hasConfiguration(provider); + + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getProviderIcon(provider), + color: theme.colorScheme.primary, + size: 24, + ), + const SizedBox(width: 12), + Text( + provider.displayName, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (hasConfiguration) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Configured', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _apiKeyControllers[provider], + decoration: InputDecoration( + labelText: 'API Key', + hintText: 'Enter your ${provider.displayName} API key', + prefixIcon: const Icon(Icons.key), + suffixIcon: IconButton( + icon: Icon( + _obscureText[provider]! + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscureText[provider] = !_obscureText[provider]!; + }); + }, + ), + border: const OutlineInputBorder(), + ), + obscureText: _obscureText[provider]!, + ), + const SizedBox(height: 12), + TextFormField( + controller: _modelControllers[provider], + decoration: InputDecoration( + labelText: 'Model (Optional)', + hintText: _getDefaultModelHint(provider), + prefixIcon: const Icon(Icons.memory), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Text( + _getProviderDescription(provider), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (hasConfiguration) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _removeConfiguration(provider), + icon: const Icon(Icons.delete_outline, size: 16), + label: const Text('Remove'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + ), + ], + ], + ), + ), + ); + } + + IconData _getProviderIcon(AIProvider provider) { + switch (provider) { + case AIProvider.openai: + return Icons.smart_toy; + case AIProvider.gemini: + return Icons.auto_awesome; + } + } + + String _getDefaultModelHint(AIProvider provider) { + switch (provider) { + case AIProvider.openai: + return 'Default: gpt-4o-mini'; + case AIProvider.gemini: + return 'Default: gemini-1.5-flash'; + } + } + + String _getProviderDescription(AIProvider provider) { + switch (provider) { + case AIProvider.openai: + return 'Get your API key from platform.openai.com/api-keys'; + case AIProvider.gemini: + return 'Get your API key from console.cloud.google.com'; + } + } + + Future _saveAllConfigurations() async { + final messenger = ScaffoldMessenger.of(context); + + try { + for (final provider in AIProvider.values) { + final apiKey = _apiKeyControllers[provider]!.text.trim(); + + if (apiKey.isNotEmpty) { + final model = _modelControllers[provider]!.text.trim().isEmpty + ? null + : _modelControllers[provider]!.text.trim(); + + final config = AIConfiguration( + provider: provider, + apiKey: apiKey, + model: model, + ); + + await AIService.instance.saveConfiguration(config); + } + } + + messenger.showSnackBar( + const SnackBar( + content: Text('Configuration saved successfully'), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text('Error saving configuration: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _removeConfiguration(AIProvider provider) async { + final messenger = ScaffoldMessenger.of(context); + + try { + await AIService.instance.removeConfiguration(provider); + setState(() { + _apiKeyControllers[provider]!.clear(); + _modelControllers[provider]!.clear(); + }); + + messenger.showSnackBar( + SnackBar( + content: Text('${provider.displayName} configuration removed'), + ), + ); + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text('Error removing configuration: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + + @override + void dispose() { + for (final controller in _apiKeyControllers.values) { + controller.dispose(); + } + for (final controller in _modelControllers.values) { + controller.dispose(); + } + super.dispose(); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index a00d735..d5acc22 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,9 +2,13 @@ // lib/main.dart +// lib/main.dart + import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:path_provider/path_provider.dart'; import 'package:snapfig/features/home/screens/home_widget.dart'; +import 'package:snapfig/features/settings/ai_settings_screen.dart'; import 'package:snapfig/shared/services/navigation_service/navigation_service.dart'; import 'package:snapfig/shared/services/ocr_core/ocr_core.dart'; import 'package:snapfig/shared/services/pdf_core/pdf_core.dart'; @@ -12,11 +16,10 @@ import 'core/theme/theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(fileName: '.env'); final dir = await getApplicationCacheDirectory(); final pdfProvider = await PDFProviderImpl.load( - ocrProvider: OCRProviderImpl( - baseUrl: 'https://901b-39-115-116-188.ngrok-free.app', - ), + ocrProvider: OCRProviderImpl(baseUrl: dotenv.env['OCR_BASE_URL']!), dbPath: dir.path, ); runApp(SnapfigApp(pdfProvider: pdfProvider)); @@ -39,6 +42,7 @@ class SnapfigApp extends StatelessWidget { darkTheme: darkTheme, navigatorKey: _navigationService.navigatorKey, home: const HomeWidget(), + routes: {'/ai-settings': (context) => const AISettingsScreen()}, ), return InheritedPDFProviderWidget( provider: _pdfProvider, @@ -48,6 +52,7 @@ class SnapfigApp extends StatelessWidget { darkTheme: darkTheme, navigatorKey: _navigationService.navigatorKey, home: const HomeWidget(), + routes: {'/ai-settings': (context) => const AISettingsScreen()}, ), ); } diff --git a/lib/shared/services/ai_service/ai_service.dart b/lib/shared/services/ai_service/ai_service.dart new file mode 100644 index 0000000..b1832dc --- /dev/null +++ b/lib/shared/services/ai_service/ai_service.dart @@ -0,0 +1,204 @@ +import 'dart:convert'; +import 'dart:ui' as ui; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'models/ai_provider.dart'; + +class AIService { + static const String _configKey = 'ai_configurations'; + + static AIService? _instance; + static AIService get instance => _instance ??= AIService._(); + AIService._(); + + List _configurations = []; + List get configurations => List.unmodifiable(_configurations); + + Future loadConfigurations() async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_configKey); + + if (jsonString != null) { + final List jsonList = json.decode(jsonString); + _configurations = jsonList + .map((json) => AIConfiguration.fromJson(json)) + .toList(); + } + } + + Future saveConfiguration(AIConfiguration config) async { + // Remove existing configuration for the same provider + _configurations.removeWhere((c) => c.provider == config.provider); + + // Add new configuration + _configurations.add(config); + + await _saveConfigurations(); + } + + Future removeConfiguration(AIProvider provider) async { + _configurations.removeWhere((c) => c.provider == provider); + await _saveConfigurations(); + } + + Future _saveConfigurations() async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = json.encode( + _configurations.map((c) => c.toJson()).toList(), + ); + await prefs.setString(_configKey, jsonString); + } + + AIConfiguration? getConfiguration(AIProvider provider) { + try { + return _configurations.firstWhere((c) => c.provider == provider); + } catch (e) { + return null; + } + } + + bool hasConfiguration(AIProvider provider) { + return getConfiguration(provider) != null; + } + + Future askAI({ + required AIProvider provider, + required String question, + String? context, + ui.Image? image, + }) async { + final config = getConfiguration(provider); + if (config == null) { + throw Exception('No configuration found for ${provider.displayName}'); + } + + switch (provider) { + case AIProvider.openai: + return _askOpenAI(config, question, context, image); + case AIProvider.gemini: + return _askGemini(config, question, context, image); + } + } + + Future _imageToBase64(ui.Image image) async { + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + throw Exception('Failed to convert image to bytes'); + } + final bytes = byteData.buffer.asUint8List(); + return base64Encode(bytes); + } + + Future _askOpenAI( + AIConfiguration config, + String question, + String? context, + ui.Image? image, + ) async { + final url = Uri.parse('https://api.openai.com/v1/chat/completions'); + + // Build message content + List> content = []; + + if (image != null) { + final base64Image = await _imageToBase64(image); + content.add({ + 'type': 'image_url', + 'image_url': { + 'url': 'data:image/png;base64,$base64Image', + 'detail': 'high' + } + }); + } + + final prompt = context != null + ? 'Based on this figure/context: $context\n\nQuestion: $question' + : question; + + content.add({ + 'type': 'text', + 'text': prompt, + }); + + final response = await http.post( + url, + headers: { + 'Authorization': 'Bearer ${config.apiKey}', + 'Content-Type': 'application/json', + }, + body: json.encode({ + 'model': config.defaultModel.contains('vision') || image != null ? 'gpt-4o' : config.defaultModel, + 'messages': [ + { + 'role': 'user', + 'content': content, + } + ], + 'max_tokens': 1000, + 'temperature': 0.7, + }), + ); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + return data['choices'][0]['message']['content']; + } else { + throw Exception('OpenAI API error: ${response.statusCode} - ${response.body}'); + } + } + + Future _askGemini( + AIConfiguration config, + String question, + String? context, + ui.Image? image, + ) async { + final url = Uri.parse( + 'https://generativelanguage.googleapis.com/v1beta/models/${config.defaultModel}:generateContent?key=${config.apiKey}' + ); + + // Build parts for the request + List> parts = []; + + if (image != null) { + final base64Image = await _imageToBase64(image); + parts.add({ + 'inline_data': { + 'mime_type': 'image/png', + 'data': base64Image + } + }); + } + + final prompt = context != null + ? 'Based on this figure/context: $context\n\nQuestion: $question' + : question; + + parts.add({'text': prompt}); + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + }, + body: json.encode({ + 'contents': [ + { + 'parts': parts + } + ], + 'generationConfig': { + 'temperature': 0.7, + 'maxOutputTokens': 1000, + } + }), + ); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + return data['candidates'][0]['content']['parts'][0]['text']; + } else { + throw Exception('Gemini API error: ${response.statusCode} - ${response.body}'); + } + } +} \ No newline at end of file diff --git a/lib/shared/services/ai_service/ai_service_export.dart b/lib/shared/services/ai_service/ai_service_export.dart new file mode 100644 index 0000000..23b916f --- /dev/null +++ b/lib/shared/services/ai_service/ai_service_export.dart @@ -0,0 +1,2 @@ +export 'ai_service.dart'; +export 'models/ai_provider.dart'; \ No newline at end of file diff --git a/lib/shared/services/ai_service/models/ai_provider.dart b/lib/shared/services/ai_service/models/ai_provider.dart new file mode 100644 index 0000000..a473cd8 --- /dev/null +++ b/lib/shared/services/ai_service/models/ai_provider.dart @@ -0,0 +1,49 @@ +enum AIProvider { + openai('OpenAI', 'openai'), + gemini('Google Gemini', 'gemini'); + + const AIProvider(this.displayName, this.key); + + final String displayName; + final String key; +} + +class AIConfiguration { + final AIProvider provider; + final String apiKey; + final String? model; + + const AIConfiguration({ + required this.provider, + required this.apiKey, + this.model, + }); + + Map toJson() { + return { + 'provider': provider.key, + 'apiKey': apiKey, + 'model': model, + }; + } + + factory AIConfiguration.fromJson(Map json) { + return AIConfiguration( + provider: AIProvider.values.firstWhere( + (p) => p.key == json['provider'], + orElse: () => AIProvider.openai, + ), + apiKey: json['apiKey'] ?? '', + model: json['model'], + ); + } + + String get defaultModel { + switch (provider) { + case AIProvider.openai: + return model ?? 'gpt-4o-mini'; + case AIProvider.gemini: + return model ?? 'gemini-1.5-flash'; + } + } +} \ No newline at end of file diff --git a/lib/shared/services/navigation_service/navigation_view_model.dart b/lib/shared/services/navigation_service/navigation_view_model.dart index a991ce3..4b8238d 100644 --- a/lib/shared/services/navigation_service/navigation_view_model.dart +++ b/lib/shared/services/navigation_service/navigation_view_model.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:snapfig/features/pdf_viewer/screens/pdf_viewer.dart'; +import 'package:snapfig/features/pdf_viewer/screens/pdf_viewer.dart'; import 'package:snapfig/shared/services/navigation_service/navigation_service.dart'; /// ViewModel for handling navigation in the MVVM pattern. @@ -15,9 +16,7 @@ class NavigationViewModel extends ChangeNotifier { /// [path] The path to the PDF file /// [isAsset] Whether the PDF is an asset file (default: false) void navigateToPdfViewer({required String path, bool isAsset = false}) { - _navigationService.navigateToWidget( - PDFViewer(path: path, isAsset: isAsset), - ); + _navigationService.navigateToWidget(PDFViewer(path: path)); } /// Navigate back to the previous screen diff --git a/lib/shared/services/ocr_core/models/bounding_box.dart b/lib/shared/services/ocr_core/models/bounding_box.dart new file mode 100644 index 0000000..7d81aec --- /dev/null +++ b/lib/shared/services/ocr_core/models/bounding_box.dart @@ -0,0 +1,39 @@ +class BBox { + final double x; + final double y; + final double width; + final double height; + + const BBox({ + required this.x, + required this.y, + required this.width, + required this.height, + }); + + Map toJson() { + return {'x': x, 'y': y, 'width': width, 'height': height}; + } + + factory BBox.fromJson(Map json) { + return BBox( + x: (json['x'] as num).toDouble(), + y: (json['y'] as num).toDouble(), + width: (json['width'] as num).toDouble(), + height: (json['height'] as num).toDouble(), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is BBox && + other.x == x && + other.y == y && + other.width == width && + other.height == height; + } + + @override + int get hashCode => Object.hash(x, y, width, height); +} diff --git a/lib/shared/services/ocr_core/models/metadata.dart b/lib/shared/services/ocr_core/models/metadata.dart new file mode 100644 index 0000000..10474d0 --- /dev/null +++ b/lib/shared/services/ocr_core/models/metadata.dart @@ -0,0 +1,23 @@ +class Metadata { + final String title; + final String author; + final int pages; + + const Metadata({ + required this.title, + required this.author, + required this.pages, + }); + + factory Metadata.fromJson(Map json) { + return Metadata( + title: json['title'] as String, + author: json['author'] as String, + pages: (json['pages'] as num).toInt(), + ); + } + + Map toJson() { + return {'title': title, 'author': author, 'pages': pages}; + } +} diff --git a/lib/shared/services/ocr_core/models/models.dart b/lib/shared/services/ocr_core/models/models.dart new file mode 100644 index 0000000..09c21e5 --- /dev/null +++ b/lib/shared/services/ocr_core/models/models.dart @@ -0,0 +1,3 @@ +export 'bounding_box.dart'; +export 'metadata.dart'; +export 'ocr_result.dart'; diff --git a/lib/shared/services/ocr_core/models/ocr_result.dart b/lib/shared/services/ocr_core/models/ocr_result.dart new file mode 100644 index 0000000..ae0cb8c --- /dev/null +++ b/lib/shared/services/ocr_core/models/ocr_result.dart @@ -0,0 +1,163 @@ +import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; +import 'package:snapfig/shared/services/ocr_core/models/metadata.dart'; + +class OCRResult { + final Metadata metadata; + final List pages; + final List
figures; + + const OCRResult({ + required this.metadata, + required this.pages, + required this.figures, + }); + + factory OCRResult.fromJson(Map json) { + return OCRResult( + metadata: Metadata.fromJson(json['metadata'] as Map), + pages: + (json['pages'] as List) + .map((e) => PageResult.fromJson(e as Map)) + .toList(), + figures: + (json['figures'] as List) + .map((e) => Figure.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'metadata': metadata.toJson(), + 'pages': pages.map((e) => e.toJson()).toList(), + 'figures': figures.map((e) => e.toJson()).toList(), + }; + } +} + +class PageResult { + final int index; + final List pageSize; + final List blocks; + final List references; + + const PageResult({ + required this.index, + required this.pageSize, + required this.blocks, + required this.references, + }); + + factory PageResult.fromJson(Map json) { + return PageResult( + index: (json['index'] as num).toInt(), + pageSize: + (json['page_size'] as List) + .map((e) => (e as num).toDouble()) + .toList(), + blocks: + (json['blocks'] as List) + .map((e) => TextResult.fromJson(e as Map)) + .toList(), + references: + (json['references'] as List) + .map((e) => Reference.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'index': index, + 'page_size': pageSize, + 'blocks': blocks.map((e) => e.toJson()).toList(), + 'references': references.map((e) => e.toJson()).toList(), + }; + } +} + +class TextResult { + final String text; + final BBox bbox; + + const TextResult({required this.text, required this.bbox}); + + factory TextResult.fromJson(Map json) { + return TextResult( + text: json['text'] as String, + bbox: BBox.fromJson(json['bbox'] as Map), + ); + } + + Map toJson() { + return {'text': text, 'bbox': bbox.toJson()}; + } +} + +class Reference { + final BBox bbox; + final String text; + final String figureId; + final bool notMatched; + + const Reference({ + required this.bbox, + required this.text, + required this.figureId, + required this.notMatched, + }); + + factory Reference.fromJson(Map json) { + return Reference( + bbox: BBox.fromJson(json['bbox'] as Map), + text: json['text'] as String, + figureId: json['figure_id'] as String? ?? '', + notMatched: json['not_matched'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'bbox': bbox.toJson(), + 'text': text, + 'figure_id': figureId, + 'not_matched': notMatched, + }; + } +} + +class Figure { + final BBox bbox; + final int pageIdx; + final String figureId; + final String type; + final String text; + + const Figure({ + required this.bbox, + required this.pageIdx, + required this.figureId, + required this.type, + required this.text, + }); + + factory Figure.fromJson(Map json) { + return Figure( + bbox: BBox.fromJson(json['bbox'] as Map), + pageIdx: (json['page_idx'] as num).toInt(), + figureId: json['figure_id'] as String? ?? '', + type: json['type'] as String, + text: json['text'] as String, + ); + } + + Map toJson() { + return { + 'bbox': bbox.toJson(), + 'page_idx': pageIdx, + 'figure_id': figureId, + 'type': type, + 'text': text, + }; + } +} diff --git a/lib/shared/services/ocr_core/ocr_core.dart b/lib/shared/services/ocr_core/ocr_core.dart index 1801a5e..1f3ff13 100644 --- a/lib/shared/services/ocr_core/ocr_core.dart +++ b/lib/shared/services/ocr_core/ocr_core.dart @@ -1,3 +1,3 @@ export 'ocr_provider.dart'; export 'ocr_provider_impl.dart'; -export 'ocr_result.dart'; +export 'models/models.dart'; diff --git a/lib/shared/services/ocr_core/ocr_provider.dart b/lib/shared/services/ocr_core/ocr_provider.dart index a2659cd..bac73ca 100644 --- a/lib/shared/services/ocr_core/ocr_provider.dart +++ b/lib/shared/services/ocr_core/ocr_provider.dart @@ -1,4 +1,4 @@ -import 'package:snapfig/shared/services/ocr_core/ocr_result.dart'; +import 'package:snapfig/shared/services/ocr_core/models/ocr_result.dart'; abstract class OCRProvider { // OCR 처리 메서드 diff --git a/lib/shared/services/ocr_core/ocr_provider_impl.dart b/lib/shared/services/ocr_core/ocr_provider_impl.dart index 8030000..bae2e02 100644 --- a/lib/shared/services/ocr_core/ocr_provider_impl.dart +++ b/lib/shared/services/ocr_core/ocr_provider_impl.dart @@ -4,11 +4,10 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:flutter_client_sse/flutter_client_sse.dart'; -import 'package:flutter_client_sse/constants/sse_request_type_enum.dart'; import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; import 'package:snapfig/shared/services/ocr_core/ocr_provider.dart'; -import 'package:snapfig/shared/services/ocr_core/ocr_result.dart'; +import 'package:snapfig/shared/services/ocr_core/models/ocr_result.dart'; import 'package:path/path.dart' as path_lib; @@ -17,12 +16,18 @@ class OCRProviderImpl extends OCRProvider { final http.Client httpClient; OCRProviderImpl({required this.baseUrl, http.Client? httpClient}) - : httpClient = httpClient ?? http.Client(); + : httpClient = httpClient ?? _createHttpClient(); + + static http.Client _createHttpClient() { + final httpClient = HttpClient(); + httpClient.badCertificateCallback = (cert, host, port) => true; + return IOClient(httpClient); + } @override Future process(String pdfPath) async { // 1) 분석 요청 - final analyzeUri = Uri.parse('$baseUrl/analyze/pdf?format=frontend'); + final analyzeUri = Uri.parse('$baseUrl/api/v1/analyze'); final pdfBytes = await File(pdfPath).readAsBytes(); final request = http.MultipartRequest('POST', analyzeUri) ..files.add( @@ -34,57 +39,10 @@ class OCRProviderImpl extends OCRProvider { ); final response = await httpClient.send(request); final analyzeRes = await http.Response.fromStream(response); - if (analyzeRes.statusCode != 202) { + if (analyzeRes.statusCode != 200) { throw Exception('OCR API 요청 실패: HTTP ${analyzeRes.statusCode}'); } - - // 2) task_id, stream_url 파싱 final bodyJson = json.decode(analyzeRes.body) as Map; - final taskId = bodyJson['task_id'] as String; - final streamUrl = '$baseUrl/analyze/stream/$taskId'; - - // 3) SSE 구독: analyze/stream/{taskId} - final completer = Completer(); - // 먼저 선언 - late StreamSubscription subscription; - // 나중에 assign - subscription = SSEClient.subscribeToSSE( - method: SSERequestType.GET, - url: streamUrl, - header: { - HttpHeaders.acceptHeader: 'text/event-stream', - 'Cache-Control': 'no-cache', - }, - ).listen( - (SSEModel event) { - try { - final data = json.decode(event.data ?? '{}') as Map; - if (data['status'] == 'completed') { - subscription.cancel(); - if (!completer.isCompleted) completer.complete(); - } - } catch (_) {} - }, - onError: (err) { - subscription.cancel(); - if (!completer.isCompleted) completer.completeError(err); - }, - ); - // 완료를 기다림 - await completer.future; - - // 4) 결과 조회: GET /result/{taskId} - final resultUri = Uri.parse('$baseUrl/result/$taskId'); - final resultReq = http.Request('GET', resultUri); - resultReq.headers[HttpHeaders.acceptHeader] = 'application/json'; - final streamedResult = await httpClient.send(resultReq); - final resultRes = await http.Response.fromStream(streamedResult); - if (resultRes.statusCode != 200) { - throw Exception('결과 조회 실패: HTTP ${resultRes.statusCode}'); - } - - // 5) JSON → 모델 매핑 - final resultJson = json.decode(resultRes.body) as Map; - return OCRResult.fromJson(resultJson); + return OCRResult.fromJson(bodyJson); } } diff --git a/lib/shared/services/ocr_core/ocr_result.dart b/lib/shared/services/ocr_core/ocr_result.dart deleted file mode 100644 index 8e95c50..0000000 --- a/lib/shared/services/ocr_core/ocr_result.dart +++ /dev/null @@ -1,288 +0,0 @@ -// lib/shared/services/ocr_core/ocr_result.dart - -/// OCR 분석 결과 모델 (프론트엔드용) -class OCRResult { - /// 문서 제목 (null 이거나 누락되면 빈 문자열) - final String title; - - /// 챕터 리스트 - final List chapters; - - /// 페이지별 상세 - final List pages; - - /// 본문 중 Figure 객체 - final List
figures; - - /// 전체 메타데이터 - final Metadata metadata; - - OCRResult({ - required this.title, - required this.chapters, - required this.pages, - required this.figures, - required this.metadata, - }); - - factory OCRResult.fromJson(Map json) { - // 1) title - final rawTitle = json['title']; - final title = rawTitle is String ? rawTitle : ''; - - // 2) chapters - final chaptersJson = json['chapters'] as List? ?? []; - final chapters = - chaptersJson - .map((e) => Chapter.fromJson(e as Map)) - .toList(); - - // 3) pages - final pagesJson = json['pages'] as List? ?? []; - final pages = - pagesJson - .map((e) => PageDetail.fromJson(e as Map)) - .toList(); - - // 4) figures - final figsJson = json['figures'] as List? ?? []; - final figures = - figsJson - .map((e) => Figure.fromJson(e as Map)) - .toList(); - - // 5) metadata - final metaJson = json['metadata'] as Map? ?? {}; - final metadata = Metadata.fromJson(metaJson); - - return OCRResult( - title: title, - chapters: chapters, - pages: pages, - figures: figures, - metadata: metadata, - ); - } -} - -/// 챕터 정보 -class Chapter { - final int chapterNumber; - final String title; - final int startPage; - final int endPage; - final List sections; - - Chapter({ - required this.chapterNumber, - required this.title, - required this.startPage, - required this.endPage, - required this.sections, - }); - - factory Chapter.fromJson(Map json) { - return Chapter( - chapterNumber: (json['chapter_number'] as num).toInt(), - title: (json['title'] as String?) ?? '', - startPage: (json['start_page'] as num).toInt(), - endPage: (json['end_page'] as num).toInt(), - sections: - (json['sections'] as List?) - ?.map((e) => e as String) - .toList() ?? - [], - ); - } -} - -/// 페이지 상세 정보 -class PageDetail { - final int pageIndex; - final String? chapter; - final String? section; - final List layouts; - final String fullText; - final int width; - final int height; - - PageDetail({ - required this.pageIndex, - this.chapter, - this.section, - required this.layouts, - required this.fullText, - required this.width, - required this.height, - }); - - factory PageDetail.fromJson(Map json) { - // layouts - final layoutsJson = json['layouts'] as List? ?? []; - final layouts = - layoutsJson - .map((e) => LayoutItem.fromJson(e as Map)) - .toList(); - final sizeJson = json['size'] as Map? ?? {}; - - return PageDetail( - pageIndex: (json['page_index'] as num).toInt(), - chapter: json['chapter'] as String?, - section: json['section'] as String?, - layouts: layouts, - fullText: (json['full_text'] as String?) ?? '', - width: ((sizeJson['width'] ?? 0) as num).toInt(), - height: ((sizeJson['height'] ?? 0) as num).toInt(), - ); - } -} - -/// Figure 정보 (예시에선 빈 배열이지만 구조를 맞춰 파싱) -class Figure { - final String id; - final List boundingBox; - final String? caption; - final int? pageIndex; - - Figure({ - required this.id, - required this.boundingBox, - this.caption, - this.pageIndex, - }); - - factory Figure.fromJson(Map json) { - return Figure( - id: json['figure_id'] as String? ?? '', - boundingBox: - (json['bounding_box'] as List? ?? []) - .map((e) => (e as num).toDouble()) - .toList(), - caption: json['caption'] as String?, - pageIndex: - json['page_index'] != null - ? (json['page_index'] as num).toInt() - : null, - ); - } -} - -/// 레이아웃 추상 클래스 -abstract class LayoutItem { - final String type; - final List boundingBox; - - LayoutItem({required this.type, required this.boundingBox}); - - factory LayoutItem.fromJson(Map json) { - switch (json['type'] as String) { - case 'text': - return TextLayout.fromJson(json); - case 'figure': - return FigureLayoutItem.fromJson(json); - case 'figure_reference': - return FigureReferenceLayout.fromJson(json); - default: - throw UnimplementedError('Unknown layout type: ${json['type']}'); - } - } -} - -/// 텍스트 레이아웃 -class TextLayout extends LayoutItem { - final String text; - final double confidence; - - TextLayout({ - required super.boundingBox, - required this.text, - required this.confidence, - }) : super(type: 'text'); - - factory TextLayout.fromJson(Map json) { - return TextLayout( - boundingBox: - (json['bounding_box'] as List) - .map((e) => (e as num).toDouble()) - .toList(), - text: json['text'] as String? ?? '', - confidence: ((json['confidence'] ?? 0) as num).toDouble(), - ); - } -} - -/// Figure 레이아웃 (본문 내 그림 박스) -class FigureLayoutItem extends LayoutItem { - final String figureId; - final String caption; - final int figureNumber; - - FigureLayoutItem({ - required super.boundingBox, - required this.figureId, - required this.caption, - required this.figureNumber, - }) : super(type: 'figure'); - - factory FigureLayoutItem.fromJson(Map json) { - return FigureLayoutItem( - boundingBox: - (json['bounding_box'] as List) - .map((e) => (e as num).toDouble()) - .toList(), - figureId: (json['figure_id'] ?? '') as String, - caption: (json['caption'] ?? '') as String, - figureNumber: ((json['figure_number'] ?? 0) as num).toInt(), - ); - } -} - -/// Figure 참조 레이아웃 (본문 내 그림 언급) -class FigureReferenceLayout extends LayoutItem { - final String referencedFigureId; - final String referenceText; - final int figureNumber; - final double confidence; - - FigureReferenceLayout({ - required super.boundingBox, - required this.referencedFigureId, - required this.referenceText, - required this.figureNumber, - required this.confidence, - }) : super(type: 'figure_reference'); - - factory FigureReferenceLayout.fromJson(Map json) { - return FigureReferenceLayout( - boundingBox: - (json['bounding_box'] as List) - .map((e) => (e as num).toDouble()) - .toList(), - referencedFigureId: json['referenced_figure_id'] as String, - referenceText: json['reference_text'] as String, - figureNumber: (json['figure_number'] as num).toInt(), - confidence: ((json['confidence'] ?? 0) as num).toDouble(), - ); - } -} - -/// 메타데이터 -class Metadata { - final int totalPages; - final double processingTime; - final int totalFigures; - - Metadata({ - required this.totalPages, - required this.processingTime, - required this.totalFigures, - }); - - factory Metadata.fromJson(Map json) { - return Metadata( - totalPages: (json['total_pages'] as num).toInt(), - processingTime: (json['processing_time'] as num).toDouble(), - totalFigures: (json['total_figures'] as num).toInt(), - ); - } -} diff --git a/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart b/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart index fbb2084..fde1a63 100644 --- a/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart +++ b/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart @@ -80,6 +80,7 @@ class PDFProviderImpl extends PDFProvider { // 주어진 PDF에 대한 OCR을 진행합니다. 이때 독립된 isolate로 변경이 되고, // 처리 결과는 주 스레드에서 처리됩니다. Future _processPdfWithOcr(PDFModel pdf) async { + _logger.severe('process OCR'); _logger.severe('process OCR'); await updatePDF(id: pdf.id, status: PDFStatus.processing); final receivePort = ReceivePort(); @@ -122,97 +123,111 @@ class PDFProviderImpl extends PDFProvider { if (ocrResult == null) throw Exception('OCR 결과 없음'); try { + final pages = ocrResult.pages; + final figures = ocrResult.figures; + + // Save to Isar database await _isar.writeTxn(() async { - // 2. 페이지별 데이터 저장 - for (final pageDetail in ocrResult.pages) { - // 페이지 모델 생성 - final pageModel = PageModel( - pageIndex: pageDetail.pageIndex, - fullText: pageDetail.fullText, - width: pageDetail.width, - height: pageDetail.height, + // Mapping OCRResult to PageModel and LayoutModel + final pageModels = []; + final allLayoutModels = []; + + for (final page in pages) { + final pageModel = PageModel.create( + pageIndex: page.index, + fullText: page.blocks.map((block) => block.text).join('\n'), + size: Size(page.pageSize[0], page.pageSize[1]), ); - await _isar.pageModels.put(pageModel); - - // 페이지와 PDF 연결 + // PDF와 PageModel 관계 설정 pageModel.pdf.value = pdf; - await pageModel.pdf.save(); - pdf.pages.add(pageModel); - // 3. 레이아웃 데이터 저장 - for (final layout in pageDetail.layouts) { - LayoutModel layoutModel; - - if (layout is TextLayout) { - layoutModel = LayoutModel( + final layoutModels = []; + // 텍스트 블록 처리 + for (final textBlock in page.blocks) { + layoutModels.add( + LayoutModel.create( type: LayoutType.text, - content: layout.text, - text: layout.text, - latex: null, - figureId: null, - figureNumber: null, - caption: null, - referencedFigureId: null, - referenceText: null, - top: layout.boundingBox[1], - left: layout.boundingBox[0], - width: layout.boundingBox[2] - layout.boundingBox[0], - height: layout.boundingBox[3] - layout.boundingBox[1], - ); - } else if (layout is FigureLayoutItem) { - layoutModel = LayoutModel( - type: LayoutType.figure, - content: layout.caption, - text: null, - latex: null, - figureId: layout.figureId, - figureNumber: layout.figureNumber, - caption: layout.caption, - referencedFigureId: null, - referenceText: null, - top: layout.boundingBox[1], - left: layout.boundingBox[0], - width: layout.boundingBox[2] - layout.boundingBox[0], - height: layout.boundingBox[3] - layout.boundingBox[1], - ); - } else if (layout is FigureReferenceLayout) { - layoutModel = LayoutModel( + content: textBlock.text, + text: textBlock.text, + latex: '', + box: Rect.fromLTWH( + textBlock.bbox.x, + textBlock.bbox.y, + textBlock.bbox.width, + textBlock.bbox.height, + ), + ), + ); + } + + // Reference 처리 + for (final ref in page.references) { + if (ref.notMatched) continue; + layoutModels.add( + LayoutModel.create( type: LayoutType.figureReference, - content: layout.referenceText, - text: layout.referenceText, - latex: null, - figureId: null, - figureNumber: layout.figureNumber, - caption: null, - referencedFigureId: layout.referencedFigureId, - referenceText: layout.referenceText, - top: layout.boundingBox[1], - left: layout.boundingBox[0], - width: layout.boundingBox[2] - layout.boundingBox[0], - height: layout.boundingBox[3] - layout.boundingBox[1], - ); - } else { - continue; // 알 수 없는 레이아웃 타입은 건너뜀 - } - - await _isar.layoutModels.put(layoutModel); - - // 레이아웃과 페이지 연결 + content: ref.text, + text: ref.text, + latex: '', + box: Rect.fromLTWH( + ref.bbox.x, + ref.bbox.y, + ref.bbox.width, + ref.bbox.height, + ), + referencedFigureId: ref.figureId, + ), + ); + } + + // Figure 처리 (해당 page에 속한 figure만) + for (final figure in figures.where((f) => f.pageIdx == page.index)) { + layoutModels.add( + LayoutModel.create( + type: LayoutType.figure, + content: figure.text, + text: figure.text, + latex: '', + box: Rect.fromLTWH( + figure.bbox.x, + figure.bbox.y, + figure.bbox.width, + figure.bbox.height, + ), + figureId: figure.figureId, + ), + ); + } + + // PageModel과 LayoutModel 관계 설정 + for (final layoutModel in layoutModels) { layoutModel.page.value = pageModel; - await layoutModel.page.save(); - pageModel.layouts.add(layoutModel); } - await pageModel.layouts.save(); + allLayoutModels.addAll(layoutModels); + pageModels.add(pageModel); } - // 4. PDF와 페이지 관계 저장 - await pdf.pages.save(); + // 1. 먼저 PageModel들을 저장 + await _isar.pageModels.putAll(pageModels); + + // 2. LayoutModel들을 저장 + await _isar.layoutModels.putAll(allLayoutModels); + + // 3. PDF 모델 저장 및 관계 설정 await _isar.pDFModels.put(pdf); + + // 4. 관계 링크들을 저장 + for (final pageModel in pageModels) { + await pageModel.pdf.save(); + } + + for (final layoutModel in allLayoutModels) { + await layoutModel.page.save(); + } }); - // 5. PDF 상태를 완료로 업데이트 (updatePDF 메서드 사용) await updatePDF( id: pdf.id, status: PDFStatus.completed, @@ -273,12 +288,14 @@ class PDFProviderImpl extends PDFProvider { }); final pdfInfo = await _getPDFInfo(filePath); pdf.thumbnail = pdfInfo.thumbnail; + pdf.thumbnail = pdfInfo.thumbnail; await updatePDF( id: pdf.id, thumbnail: pdfInfo.thumbnail, totalPages: pdfInfo.totalPages, ); _processPdfWithOcr(pdf); + _processPdfWithOcr(pdf); } catch (e) { _logger.severe('Failed to add PDF: $e'); } @@ -341,6 +358,11 @@ class PDFProviderImpl extends PDFProvider { return await _isar.pDFModels.filter().pathEqualTo(path).findFirst(); } + @override + Future getPDF(String path) async { + return await _isar.pDFModels.filter().pathEqualTo(path).findFirst(); + } + @override void dispose() { _databaseSubscription.cancel(); diff --git a/lib/shared/services/summarizer_core/summarizer.dart b/lib/shared/services/summarizer_core/summarizer.dart new file mode 100644 index 0000000..ca2358d --- /dev/null +++ b/lib/shared/services/summarizer_core/summarizer.dart @@ -0,0 +1,4 @@ +abstract class Summarizer { + Future summarizeText(String text); + Future summarizeFigure(String pdfPath, String xref); +} diff --git a/lib/shared/services/summarizer_core/summarizer_core.dart b/lib/shared/services/summarizer_core/summarizer_core.dart new file mode 100644 index 0000000..0e64dfa --- /dev/null +++ b/lib/shared/services/summarizer_core/summarizer_core.dart @@ -0,0 +1,2 @@ +export 'summarizer.dart'; +export 'summarizer_impl.dart'; \ No newline at end of file diff --git a/lib/shared/services/summarizer_core/summarizer_impl.dart b/lib/shared/services/summarizer_core/summarizer_impl.dart new file mode 100644 index 0000000..c792775 --- /dev/null +++ b/lib/shared/services/summarizer_core/summarizer_impl.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; +import 'package:snapfig/shared/services/summarizer_core/summarizer.dart'; + +class SummarizerImpl extends Summarizer { + final String baseUrl; + final http.Client httpClient; + + SummarizerImpl({required this.baseUrl, http.Client? httpClient}) + : httpClient = httpClient ?? http.Client(); + + @override + Future summarizeText(String text) async { + final uri = Uri.parse('$baseUrl/api/v1/summarize/text'); + + try { + final response = await httpClient.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: json.encode({'text': text}), + ); + + if (response.statusCode == 200) { + final responseData = json.decode(response.body) as Map; + final summaries = responseData['summaries'] as List; + + // Concatenate all summaries into a single string + final summarizedText = summaries + .map((item) => item['summary'] as String) + .join('\n\n'); + + return summarizedText; + } else if (response.statusCode == 400) { + throw Exception('Invalid request: text field is missing.'); + } else if (response.statusCode == 503) { + throw Exception('Service unavailable'); + } else { + throw Exception('API request failed: HTTP ${response.statusCode}'); + } + } catch (e) { + if (e is Exception) { + rethrow; + } + throw Exception('Error: $e'); + } + } + + @override + Future summarizeFigure(String pdfPath, String xref) async { + final uri = Uri.parse('$baseUrl/api/v1/summarize/figure'); + + // Extract filename from path + final pdfFilename = path.basename(pdfPath); + + try { + final response = await httpClient.post( + uri, + headers: { + 'Content-Type': 'application/json', + }, + body: json.encode({ + 'pdf_filename': pdfFilename, + 'xref': int.tryParse(xref) ?? xref, + }), + ); + + if (response.statusCode == 200) { + final responseData = json.decode(response.body) as Map; + return responseData['summary'] as String; + } else if (response.statusCode == 400) { + throw Exception('pdf_filename 또는 xref 필드가 누락되었습니다.'); + } else if (response.statusCode == 500) { + throw Exception('도표 요약 처리 실패'); + } else { + throw Exception('도표 요약 API 요청 실패: HTTP ${response.statusCode}'); + } + } catch (e) { + if (e is Exception) { + rethrow; + } + throw Exception('도표 요약 중 오류 발생: $e'); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index b5a1698..e27adff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -125,74 +125,10 @@ packages: dependency: transitive description: name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 + sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" url: "https://pub.dev" source: hosted - version: "8.9.5" - build: - dependency: transitive - description: - name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" - url: "https://pub.dev" - source: hosted - version: "1.1.2" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" - url: "https://pub.dev" - source: hosted - version: "2.4.13" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 - url: "https://pub.dev" - source: hosted - version: "7.3.2" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 - url: "https://pub.dev" - source: hosted - version: "8.9.5" + version: "8.10.1" characters: dependency: transitive description: @@ -422,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_driver: dependency: transitive description: flutter @@ -491,6 +435,7 @@ packages: source: hosted version: "2.3.2" http: + dependency: "direct main" dependency: "direct main" description: name: http @@ -663,6 +608,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.4" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" nested: dependency: transitive description: @@ -747,10 +700,10 @@ packages: dependency: "direct main" description: name: pdfrx - sha256: "90747e916a64366b8beb69e9ac175e9134df520110543ab284b839102bf4b7e1" + sha256: "8e4a4dbb19c8af3a098bcdaac388fb41b30c53a8d9ce52a7620ba33d5d222ed0" url: "https://pub.dev" source: hosted - version: "1.1.29" + version: "1.1.33" platform: dependency: transitive description: @@ -939,10 +892,10 @@ packages: dependency: "direct main" description: name: pdfrx - sha256: "90747e916a64366b8beb69e9ac175e9134df520110543ab284b839102bf4b7e1" + sha256: "8e4a4dbb19c8af3a098bcdaac388fb41b30c53a8d9ce52a7620ba33d5d222ed0" url: "https://pub.dev" source: hosted - version: "1.1.29" + version: "1.1.33" platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d1c5ae6..cc5796d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: http: ^1.4.0 flutter_client_sse: ^2.0.3 + flutter_dotenv: ^5.1.0 dev_dependencies: flutter_test: @@ -77,6 +78,7 @@ dev_dependencies: isar_generator: ^3.1.0+1 build_runner: ^2.4.13 mockito: ^5.4.4 + mockito: ^5.4.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/unit/summarizer_test.dart b/test/unit/summarizer_test.dart new file mode 100644 index 0000000..3065ac0 --- /dev/null +++ b/test/unit/summarizer_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:snapfig/shared/services/summarizer_core/summarizer_core.dart'; +import 'dart:convert'; + +void main() { + group('SummarizerImpl', () { + late Summarizer summarizer; + late MockClient mockClient; + const baseUrl = 'https://test-api.example.com'; + + setUp(() { + mockClient = MockClient((request) async { + if (request.url.path == '/api/v1/summarize/text' && + request.method == 'POST') { + final body = json.decode(request.body) as Map; + + if (!body.containsKey('text')) { + return http.Response('{"error": "Missing text field"}', 400); + } + + return http.Response( + json.encode({ + 'summaries': [ + { + 'original': 'The first original paragraph', + 'summary': 'The first summarized paragraph.', + }, + { + 'original': 'The second original paragraph', + 'summary': 'The second summarized paragraph.', + }, + ], + }), + 200, + ); + } + + return http.Response('Not Found', 404); + }); + + summarizer = SummarizerImpl(baseUrl: baseUrl, httpClient: mockClient); + }); + + test('should successfully summarize text', () async { + const inputText = 'Some long text that needs to be summarized'; + + final result = await summarizer.summarizeText(inputText); + + expect( + result, + equals( + 'The first summarized paragraph.\n\nThe second summarized paragraph.', + ), + ); + }); + + test('should throw exception on 400 error', () async { + mockClient = MockClient((request) async { + return http.Response('{"error": "Missing text field"}', 400); + }); + + summarizer = SummarizerImpl(baseUrl: baseUrl, httpClient: mockClient); + + expect( + () => summarizer.summarizeText('test'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('요청에 text 필드가 없습니다'), + ), + ), + ); + }); + + test('should throw exception on 503 error', () async { + mockClient = MockClient((request) async { + return http.Response('{"error": "Service unavailable"}', 503); + }); + + summarizer = SummarizerImpl(baseUrl: baseUrl, httpClient: mockClient); + + expect( + () => summarizer.summarizeText('test'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('요약 처리 실패 (내부 서비스 오류)'), + ), + ), + ); + }); + + test('should successfully summarize figure', () async { + mockClient = MockClient((request) async { + if (request.url.path == '/api/v1/summarize/figure' && + request.method == 'POST') { + final body = json.decode(request.body) as Map; + + if (!body.containsKey('pdf_filename') || !body.containsKey('xref')) { + return http.Response('{"error": "Missing required fields"}', 400); + } + + return http.Response( + json.encode({ + 'summary': 'This figure shows a bar chart representing sales data.', + }), + 200, + ); + } + + return http.Response('Not Found', 404); + }); + + summarizer = SummarizerImpl(baseUrl: baseUrl, httpClient: mockClient); + + final result = await summarizer.summarizeFigure('/path/to/example.pdf', '5'); + + expect(result, equals('This figure shows a bar chart representing sales data.')); + }); + + test('summarizeFigure should throw exception on 400 error', () async { + mockClient = MockClient((request) async { + return http.Response('{"error": "Missing required fields"}', 400); + }); + + summarizer = SummarizerImpl(baseUrl: baseUrl, httpClient: mockClient); + + expect( + () => summarizer.summarizeFigure('/path/to/pdf', '5'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('pdf_filename 또는 xref 필드가 누락되었습니다'), + ), + ), + ); + }); + + test('summarizeFigure should throw exception on 500 error', () async { + mockClient = MockClient((request) async { + return http.Response('{"error": "Internal server error"}', 500); + }); + + summarizer = SummarizerImpl(baseUrl: baseUrl, httpClient: mockClient); + + expect( + () => summarizer.summarizeFigure('/path/to/pdf', '5'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('도표 요약 처리 실패'), + ), + ), + ); + }); + }); +}