From 7b7bff5017c85bb6bf87b05a6684584f1060f8f3 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Mon, 9 Jun 2025 13:56:00 +0900 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20=EC=9A=94=EC=95=BD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Summarizer 인터페이스 및 SummarizerImpl 클래스 추가 - 텍스트 요약 및 도표 요약 기능 구현 - API 요청 처리 및 예외 처리 로직 포함 * 테스트 케이스 추가 - SummarizerImpl의 텍스트 및 도표 요약 기능에 대한 단위 테스트 구현 --- .../services/summarizer_core/summarizer.dart | 4 + .../summarizer_core/summarizer_core.dart | 2 + .../summarizer_core/summarizer_impl.dart | 85 +++++++++ test/unit/summarizer_test.dart | 163 ++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 lib/shared/services/summarizer_core/summarizer.dart create mode 100644 lib/shared/services/summarizer_core/summarizer_core.dart create mode 100644 lib/shared/services/summarizer_core/summarizer_impl.dart create mode 100644 test/unit/summarizer_test.dart 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/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('도표 요약 처리 실패'), + ), + ), + ); + }); + }); +} From 3b80bcb9e29a300b5c9601aa727bd61a717cb582 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Mon, 9 Jun 2025 14:17:26 +0900 Subject: [PATCH 02/17] =?UTF-8?q?refactor:=20OCR=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20*=20ocr=5Fcore.dart=EC=97=90=EC=84=9C=20ocr=5Fresul?= =?UTF-8?q?t.dart=EB=A5=BC=20models.dart=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20*=20OCRProviderImpl=EC=97=90=EC=84=9C=20AP?= =?UTF-8?q?I=20=EC=9A=94=EC=B2=AD=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=84=EC=86=8C=ED=99=94=20=EB=B0=8F=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20*=20=EC=83=88=EB=A1=9C=EC=9A=B4=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80:=20Annotati?= =?UTF-8?q?onLink,=20FigureLink,=20UncaptionedImage,=20BoundingBox,=20Inte?= =?UTF-8?q?ractiveElement=20*=20OCRResult=20=EB=AA=A8=EB=8D=B8=EC=9D=84=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=ED=95=98=EC=97=AC=20interactiveElements=20?= =?UTF-8?q?=EB=B0=8F=20paragraphData=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20*=20=EA=B8=B0=EC=A1=B4=20ocr=5Fresult.dart=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ocr_core/models/annotation_link.dart | 47 +++ .../ocr_core/models/bounding_box.dart | 44 +++ .../services/ocr_core/models/figure_link.dart | 51 ++++ .../ocr_core/models/interactive_element.dart | 29 ++ .../services/ocr_core/models/models.dart | 6 + .../services/ocr_core/models/ocr_result.dart | 115 +++++++ .../ocr_core/models/uncaptioned_image.dart | 51 ++++ lib/shared/services/ocr_core/ocr_core.dart | 2 +- .../services/ocr_core/ocr_provider.dart | 2 +- .../services/ocr_core/ocr_provider_impl.dart | 55 +--- lib/shared/services/ocr_core/ocr_result.dart | 288 ------------------ 11 files changed, 348 insertions(+), 342 deletions(-) create mode 100644 lib/shared/services/ocr_core/models/annotation_link.dart create mode 100644 lib/shared/services/ocr_core/models/bounding_box.dart create mode 100644 lib/shared/services/ocr_core/models/figure_link.dart create mode 100644 lib/shared/services/ocr_core/models/interactive_element.dart create mode 100644 lib/shared/services/ocr_core/models/models.dart create mode 100644 lib/shared/services/ocr_core/models/ocr_result.dart create mode 100644 lib/shared/services/ocr_core/models/uncaptioned_image.dart delete mode 100644 lib/shared/services/ocr_core/ocr_result.dart diff --git a/lib/shared/services/ocr_core/models/annotation_link.dart b/lib/shared/services/ocr_core/models/annotation_link.dart new file mode 100644 index 0000000..08942ea --- /dev/null +++ b/lib/shared/services/ocr_core/models/annotation_link.dart @@ -0,0 +1,47 @@ +import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; +import 'package:snapfig/shared/services/ocr_core/models/interactive_element.dart'; + +class AnnotationLink extends InteractiveElement { + final String targetText; + final String someOptionalField; + + const AnnotationLink({ + required super.pageNum, + required super.referenceBbox, + required this.targetText, + this.someOptionalField = 'default_value', + }); + + @override + Map toJson() { + final json = super.toJson(); + json['target_text'] = targetText; + json['some_optional_field'] = someOptionalField; + return json; + } + + factory AnnotationLink.fromJson(Map json) { + return AnnotationLink( + pageNum: json['page_num'] as int, + referenceBbox: BoundingBox.fromJson( + json['reference_bbox'] as Map, + ), + targetText: json['target_text'] as String, + someOptionalField: + json['some_optional_field'] as String? ?? 'default_value', + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AnnotationLink && + super == other && + other.targetText == targetText && + other.someOptionalField == someOptionalField; + } + + @override + int get hashCode => + Object.hash(super.hashCode, targetText, someOptionalField); +} 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..e55734a --- /dev/null +++ b/lib/shared/services/ocr_core/models/bounding_box.dart @@ -0,0 +1,44 @@ +class BoundingBox { + final double x0; + final double y0; + final double x1; + final double y1; + + const BoundingBox({ + required this.x0, + required this.y0, + required this.x1, + required this.y1, + }); + + Map toJson() { + return { + 'x0': x0, + 'y0': y0, + 'x1': x1, + 'y1': y1, + }; + } + + factory BoundingBox.fromJson(Map json) { + return BoundingBox( + x0: (json['x0'] as num).toDouble(), + y0: (json['y0'] as num).toDouble(), + x1: (json['x1'] as num).toDouble(), + y1: (json['y1'] as num).toDouble(), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is BoundingBox && + other.x0 == x0 && + other.y0 == y0 && + other.x1 == x1 && + other.y1 == y1; + } + + @override + int get hashCode => Object.hash(x0, y0, x1, y1); +} \ No newline at end of file diff --git a/lib/shared/services/ocr_core/models/figure_link.dart b/lib/shared/services/ocr_core/models/figure_link.dart new file mode 100644 index 0000000..b0a01fa --- /dev/null +++ b/lib/shared/services/ocr_core/models/figure_link.dart @@ -0,0 +1,51 @@ +import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; +import 'package:snapfig/shared/services/ocr_core/models/interactive_element.dart'; + +class FigureLink extends InteractiveElement { + final int targetXref; + final String pdfFilename; + final String elementType; + + const FigureLink({ + required super.pageNum, + required super.referenceBbox, + required this.targetXref, + required this.pdfFilename, + this.elementType = 'figure', + }); + + @override + Map toJson() { + final json = super.toJson(); + json['target_xref'] = targetXref; + json['pdf_filename'] = pdfFilename; + json['element_type'] = elementType; + return json; + } + + factory FigureLink.fromJson(Map json) { + return FigureLink( + pageNum: json['page_num'] as int, + referenceBbox: BoundingBox.fromJson( + json['reference_bbox'] as Map, + ), + targetXref: json['target_xref'] as int, + pdfFilename: json['pdf_filename'] as String, + elementType: json['element_type'] as String? ?? 'figure', + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is FigureLink && + super == other && + other.targetXref == targetXref && + other.pdfFilename == pdfFilename && + other.elementType == elementType; + } + + @override + int get hashCode => + Object.hash(super.hashCode, targetXref, pdfFilename, elementType); +} diff --git a/lib/shared/services/ocr_core/models/interactive_element.dart b/lib/shared/services/ocr_core/models/interactive_element.dart new file mode 100644 index 0000000..bb73ba9 --- /dev/null +++ b/lib/shared/services/ocr_core/models/interactive_element.dart @@ -0,0 +1,29 @@ +import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; + +abstract class InteractiveElement { + final int pageNum; + final BoundingBox referenceBbox; + + const InteractiveElement({ + required this.pageNum, + required this.referenceBbox, + }); + + Map toJson() { + return { + 'page_num': pageNum, + 'reference_bbox': referenceBbox.toJson(), + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is InteractiveElement && + other.pageNum == pageNum && + other.referenceBbox == referenceBbox; + } + + @override + int get hashCode => Object.hash(pageNum, referenceBbox); +} \ No newline at end of file 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..d3f7e4b --- /dev/null +++ b/lib/shared/services/ocr_core/models/models.dart @@ -0,0 +1,6 @@ +export 'annotation_link.dart'; +export 'bounding_box.dart'; +export 'figure_link.dart'; +export 'interactive_element.dart'; +export 'ocr_result.dart'; +export 'uncaptioned_image.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..7a786dd --- /dev/null +++ b/lib/shared/services/ocr_core/models/ocr_result.dart @@ -0,0 +1,115 @@ +import 'package:snapfig/shared/services/ocr_core/models/annotation_link.dart'; +import 'package:snapfig/shared/services/ocr_core/models/figure_link.dart'; +import 'package:snapfig/shared/services/ocr_core/models/interactive_element.dart'; +import 'package:snapfig/shared/services/ocr_core/models/uncaptioned_image.dart'; + +class TextBlock { + final String text; + + const TextBlock({required this.text}); + + factory TextBlock.fromJson(Map json) { + return TextBlock(text: json['text'] as String); + } + + Map toJson() { + return {'text': text}; + } +} + +class PageData { + final int pageNum; + final List blocks; + + const PageData({required this.pageNum, required this.blocks}); + + factory PageData.fromJson(Map json) { + return PageData( + pageNum: json['page_num'] as int, + blocks: + (json['blocks'] as List) + .map((block) => TextBlock.fromJson(block as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'page_num': pageNum, + 'blocks': blocks.map((block) => block.toJson()).toList(), + }; + } +} + +class ParagraphData { + final List pages; + + const ParagraphData({required this.pages}); + + factory ParagraphData.fromJson(Map json) { + return ParagraphData( + pages: + (json['pages'] as List) + .map((page) => PageData.fromJson(page as Map)) + .toList(), + ); + } + + Map toJson() { + return {'pages': pages.map((page) => page.toJson()).toList()}; + } +} + +class OCRResult { + final List interactiveElements; + final ParagraphData paragraphData; + + const OCRResult({ + required this.interactiveElements, + required this.paragraphData, + }); + + factory OCRResult.fromJson(Map json) { + final elementsJson = json['interactive_elements'] as List; + final elements = + elementsJson.map((element) { + final elementMap = element as Map; + + // Determine the type based on element_type field or presence of specific fields + if (elementMap.containsKey('element_type')) { + switch (elementMap['element_type']) { + case 'figure': + return FigureLink.fromJson(elementMap); + case 'uncaptioned_image': + return UncaptionedImage.fromJson(elementMap); + } + } + + // Check for type-specific fields + if (elementMap.containsKey('target_text')) { + return AnnotationLink.fromJson(elementMap); + } else if (elementMap.containsKey('target_xref')) { + return FigureLink.fromJson(elementMap); + } else if (elementMap.containsKey('xref')) { + return UncaptionedImage.fromJson(elementMap); + } + + throw Exception('Unknown interactive element type: $elementMap'); + }).toList(); + + return OCRResult( + interactiveElements: elements, + paragraphData: ParagraphData.fromJson( + json['paragraph_data'] as Map, + ), + ); + } + + Map toJson() { + return { + 'interactive_elements': + interactiveElements.map((element) => element.toJson()).toList(), + 'paragraph_data': paragraphData.toJson(), + }; + } +} diff --git a/lib/shared/services/ocr_core/models/uncaptioned_image.dart b/lib/shared/services/ocr_core/models/uncaptioned_image.dart new file mode 100644 index 0000000..8cb68f8 --- /dev/null +++ b/lib/shared/services/ocr_core/models/uncaptioned_image.dart @@ -0,0 +1,51 @@ +import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; +import 'package:snapfig/shared/services/ocr_core/models/interactive_element.dart'; + +class UncaptionedImage extends InteractiveElement { + final int xref; + final String pdfFilename; + final String elementType; + + const UncaptionedImage({ + required super.pageNum, + required super.referenceBbox, + required this.xref, + required this.pdfFilename, + this.elementType = 'uncaptioned_image', + }); + + @override + Map toJson() { + final json = super.toJson(); + json['xref'] = xref; + json['pdf_filename'] = pdfFilename; + json['element_type'] = elementType; + return json; + } + + factory UncaptionedImage.fromJson(Map json) { + return UncaptionedImage( + pageNum: json['page_num'] as int, + referenceBbox: BoundingBox.fromJson( + json['reference_bbox'] as Map, + ), + xref: json['xref'] as int, + pdfFilename: json['pdf_filename'] as String, + elementType: json['element_type'] as String? ?? 'uncaptioned_image', + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is UncaptionedImage && + super == other && + other.xref == xref && + other.pdfFilename == pdfFilename && + other.elementType == elementType; + } + + @override + int get hashCode => + Object.hash(super.hashCode, xref, pdfFilename, elementType); +} 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..a68e9c5 100644 --- a/lib/shared/services/ocr_core/ocr_provider_impl.dart +++ b/lib/shared/services/ocr_core/ocr_provider_impl.dart @@ -4,11 +4,9 @@ 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: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; @@ -22,7 +20,7 @@ class OCRProviderImpl extends OCRProvider { @override Future process(String pdfPath) async { // 1) 분석 요청 - final analyzeUri = Uri.parse('$baseUrl/analyze/pdf?format=frontend'); + final analyzeUri = Uri.parse('$baseUrl/process'); final pdfBytes = await File(pdfPath).readAsBytes(); final request = http.MultipartRequest('POST', analyzeUri) ..files.add( @@ -37,54 +35,7 @@ class OCRProviderImpl extends OCRProvider { if (analyzeRes.statusCode != 202) { 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(), - ); - } -} From 1b6036ef42d2350084a3e620524c27670f1fd82b Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Mon, 9 Jun 2025 17:33:25 +0900 Subject: [PATCH 03/17] =?UTF-8?q?fix:=20OCRResult=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B3=80=ED=99=94=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20PDFViewer=20=EC=9C=84=EC=A0=AF=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20-=20=EC=83=88=EB=A1=9C=EC=9A=B4=20API?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20OCRResult=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- integration_test/ocr_provider_test.dart | 8 +- integration_test/pdf_provider_test.dart | 9 +- .../pdf_viewer/models/figure_info.dart | 0 .../pdf_viewer/models/ocr_text_block.dart | 0 .../pdf_viewer/models/pdf_document_model.dart | 100 --- .../pdf_viewer/models/pdf_layout_model.dart | 125 ---- .../pdf_viewer/models/pdf_page_model.dart | 109 ---- .../models/pdf_viewer_view_model.dart | 231 ------- .../models/pdf_viewer_viewmodel.dart | 115 ++++ .../pdf_viewer/screens/pdf_viewer.dart | 592 ++---------------- .../pdf_viewer/screens/pdf_viewer_screen.dart | 385 ------------ .../pdf_viewer/widgets/document_card.dart | 1 - .../pdf_viewer/widgets/figure_list_panel.dart | 0 .../widgets/figure_overlay_widget.dart | 19 + .../pdf_viewer/widgets/pdf_page_viewer.dart | 175 ------ .../pdf_viewer/widgets/pdf_sidebar.dart | 193 ------ .../widgets/sidebar/figure_sidebar.dart | 44 ++ .../widgets/sidebar/outline_sidebar.dart | 47 ++ .../widgets/sidebar/pdf_sidebar.dart | 71 +++ .../navigation_view_model.dart | 4 +- .../pdf_core/provider/pdf_provider_impl.dart | 158 +++-- 21 files changed, 423 insertions(+), 1963 deletions(-) delete mode 100644 lib/features/pdf_viewer/models/figure_info.dart delete mode 100644 lib/features/pdf_viewer/models/ocr_text_block.dart delete mode 100644 lib/features/pdf_viewer/models/pdf_document_model.dart delete mode 100644 lib/features/pdf_viewer/models/pdf_layout_model.dart delete mode 100644 lib/features/pdf_viewer/models/pdf_page_model.dart delete mode 100644 lib/features/pdf_viewer/models/pdf_viewer_view_model.dart create mode 100644 lib/features/pdf_viewer/models/pdf_viewer_viewmodel.dart delete mode 100644 lib/features/pdf_viewer/screens/pdf_viewer_screen.dart delete mode 100644 lib/features/pdf_viewer/widgets/document_card.dart delete mode 100644 lib/features/pdf_viewer/widgets/figure_list_panel.dart create mode 100644 lib/features/pdf_viewer/widgets/figure_overlay_widget.dart delete mode 100644 lib/features/pdf_viewer/widgets/pdf_page_viewer.dart delete mode 100644 lib/features/pdf_viewer/widgets/pdf_sidebar.dart create mode 100644 lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart create mode 100644 lib/features/pdf_viewer/widgets/sidebar/outline_sidebar.dart create mode 100644 lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart diff --git a/integration_test/ocr_provider_test.dart b/integration_test/ocr_provider_test.dart index 8d5d163..0908bfe 100644 --- a/integration_test/ocr_provider_test.dart +++ b/integration_test/ocr_provider_test.dart @@ -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.paragraphData.pages, isNotEmpty); + expect(result.interactiveElements, 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..93f1dfd 100644 --- a/integration_test/pdf_provider_test.dart +++ b/integration_test/pdf_provider_test.dart @@ -20,12 +20,9 @@ Future copyAssetToLocal(String assetPath) async { class DummyOcrService implements OCRProvider { @override Future process(String pdfPath) async { - return OCRResult( - title: 'test', - chapters: [], - pages: [], - figures: [], - metadata: Metadata(totalPages: 0, processingTime: 0, totalFigures: 0), + return const OCRResult( + interactiveElements: [], + paragraphData: ParagraphData(pages: []), ); } } 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_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/models/pdf_viewer_view_model.dart b/lib/features/pdf_viewer/models/pdf_viewer_view_model.dart deleted file mode 100644 index f94e6b6..0000000 --- a/lib/features/pdf_viewer/models/pdf_viewer_view_model.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:file_picker/file_picker.dart'; - -import '../../../shared/services/pdf_core/pdf_core.dart'; - -class PdfViewerViewModel extends ChangeNotifier { - // Private state - BasePdf? _pdfModel; - List _pages = []; - List _layouts = []; - Map> _layoutsByPage = {}; - - // UI state - double _zoomPercent = 100.0; - int _currentPage = 1; - String _pdfTitle = 'PDF Viewer'; - String _searchQuery = ''; - bool _sidebarVisible = true; - int _selectedTabIndex = 0; - bool _isLoading = false; - String? _errorMessage; - - // Dependencies - final PDFProvider _pdfProvider; - - PdfViewerViewModel({required PDFProvider pdfProvider}) - : _pdfProvider = pdfProvider; - - BasePdf? get pdfModel => _pdfModel; - List get pages => _pages; - List get layouts => _layouts; - - double get zoomPercent => _zoomPercent; - int get currentPage => _currentPage; - String get pdfTitle => _pdfTitle; - String get searchQuery => _searchQuery; - bool get sidebarVisible => _sidebarVisible; - int get selectedTabIndex => _selectedTabIndex; - bool get isLoading => _isLoading; - String? get errorMessage => _errorMessage; - - // Filtered data - List get filteredPages => - _pages - .where((p) => p.pageIndex.toString().contains(_searchQuery)) - .toList(); - - List get filteredLayouts => - _layouts - .where((l) => l.type == LayoutType.figure) - .where( - (l) => - l.figureId?.toLowerCase().contains( - _searchQuery.toLowerCase(), - ) ?? - false, - ) - .toList(); - - // Get layouts for a specific page - List getLayoutsForPage(int pageIndex) { - return _layoutsByPage[pageIndex] ?? []; - } - - // Get figure references for a specific page - List getFigureReferencesForPage(int pageIndex) { - return getLayoutsForPage( - pageIndex, - ).where((layout) => layout.type == LayoutType.figureReference).toList(); - } - - // Get all figure references - List get figureReferences => - _layouts - .where((layout) => layout.type == LayoutType.figureReference) - .toList(); - - // Get all figures - List get figures => - _layouts.where((layout) => layout.type == LayoutType.figure).toList(); - - // Find figure by ID - BaseLayout? findFigureById(String figureId) { - try { - return figures.firstWhere((figure) => figure.figureId == figureId); - } catch (e) { - return null; - } - } - - // Find figure by referenced figure ID - BaseLayout? findFigureByReference(BaseLayout reference) { - if (reference.type != LayoutType.figureReference) return null; - final referencedId = reference.referencedFigureId; - if (referencedId == null) return null; - return findFigureById(referencedId); - } - - // Get page containing a specific layout - BasePage? getPageForLayout(BaseLayout layout) { - for (final entry in _layoutsByPage.entries) { - if (entry.value.contains(layout)) { - return _pages.firstWhere((page) => page.pageIndex == entry.key); - } - } - return null; - } - - // Navigate to a specific figure by finding its page - int? getPageNumberForFigure(BaseLayout figure) { - if (figure.type != LayoutType.figure) return null; - - final page = getPageForLayout(figure); - if (page != null) { - return page.pageIndex + - 1; // Convert to 1-based page number for PDF controller - } - return null; - } - - // Methods - Future loadPdf({required String path, required bool isAsset}) async { - try { - _setLoading(true); - _clearError(); - - // 2. Get PDF from provider by path - final model = await _pdfProvider.getPDF(path); - - if (model == null) { - throw StateError('PDF not found in provider: $path'); - } - - // 3. Load pages and layouts - final pages = await model.getPages(); - final allLayouts = []; - final layoutsByPage = >{}; - - for (final page in pages) { - final layouts = await page.getLayouts(); - allLayouts.addAll(layouts); - layoutsByPage[page.pageIndex] = layouts; - } - - // 4. Update state - _pdfModel = model; - _pages = pages; - _layouts = allLayouts; - _layoutsByPage = layoutsByPage; - _currentPage = 1; - _zoomPercent = 100.0; - _pdfTitle = model.name; - - notifyListeners(); - } catch (e) { - _setError('Failed to load PDF: $e'); - } finally { - _setLoading(false); - } - } - - Future pickPdfFromDevice() async { - try { - 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); - } - } catch (e) { - _setError('Failed to pick PDF: $e'); - } - } - - void updateZoom(double scale) { - _zoomPercent = (scale * 100).roundToDouble(); - notifyListeners(); - } - - void updateCurrentPage(int pageIndex) { - _currentPage = pageIndex; - notifyListeners(); - } - - void updateSearchQuery(String query) { - _searchQuery = query; - notifyListeners(); - } - - void toggleSidebar() { - _sidebarVisible = !_sidebarVisible; - notifyListeners(); - } - - void selectTab(int index) { - _selectedTabIndex = index; - notifyListeners(); - } - - void clearSearch() { - _searchQuery = ''; - notifyListeners(); - } - - void clearError() { - _clearError(); - } - - // Private helpers - void _setLoading(bool loading) { - _isLoading = loading; - notifyListeners(); - } - - void _setError(String error) { - _errorMessage = error; - notifyListeners(); - } - - void _clearError() { - _errorMessage = null; - notifyListeners(); - } - - @override - void dispose() { - super.dispose(); - } -} diff --git a/lib/features/pdf_viewer/models/pdf_viewer_viewmodel.dart b/lib/features/pdf_viewer/models/pdf_viewer_viewmodel.dart new file mode 100644 index 0000000..6a3fca6 --- /dev/null +++ b/lib/features/pdf_viewer/models/pdf_viewer_viewmodel.dart @@ -0,0 +1,115 @@ +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'; + +class PDFDataViewModel extends ChangeNotifier { + final PDFProvider _pdfProvider; + // Pdf Ocr result + BasePdf? _pdfModel; + List _pages = []; + Map> _layoutsByPage = {}; + // Pdf data + PdfDocument? _pdfDocument; + bool _isLoading = true; + bool _isError = false; + String? _errorMessage; + List _outlines = []; + + 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() { + _pdfDocument?.dispose(); + super.dispose(); + } + + void loadPDF(String path) async { + _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; + _pdfDocument = await PdfDocument.openFile(path); + _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; + notifyListeners(); + } + + int? getPageNumberForFigure(BaseLayout figure) { + if (figure.type != LayoutType.figure) return null; + return _layoutsByPage.entries + .firstWhereOrNull((entry) => entry.value.contains(figure)) + ?.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) return null; + final pageIndex = getPageNumberForFigure(figure); + if (pageIndex == null) return null; + final pageDoc = _pdfDocument!.pages.firstWhereOrNull( + (p) => p.pageNumber == pageIndex, + ); + if (pageDoc == null) return null; + + final scaleX = pageDoc.width / _pages[pageIndex].width; + final scaleY = pageDoc.height / _pages[pageIndex].height; + final figureRect = figure.rect; + final left = figureRect.left * scaleX; + final top = figureRect.top * scaleY; + final width = figureRect.width * scaleX; + final height = figureRect.height * scaleY; + + final image = await pageDoc.render( + x: left.toInt(), + y: top.toInt(), + width: width.toInt(), + height: height.toInt(), + ); + return image; + } +} diff --git a/lib/features/pdf_viewer/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index a507a36..cac7ef5 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:pdfrx/pdfrx.dart'; +import 'package:snapfig/features/pdf_viewer/models/pdf_viewer_viewmodel.dart'; +import 'package:snapfig/features/pdf_viewer/widgets/sidebar/pdf_sidebar.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'; @@ -16,17 +16,17 @@ 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; @override void initState() { @@ -36,35 +36,20 @@ class _PDFViewerState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { final pdfProvider = InheritedPDFProviderWidget.of(context).provider; setState(() { - _viewModel = PdfViewerViewModel(pdfProvider: pdfProvider); + _viewModel = PDFDataViewModel(pdfProvider: pdfProvider); }); // Load initial PDF - _viewModel!.loadPdf(path: widget.path, isAsset: widget.isAsset); + _viewModel!.loadPDF(widget.path); }); - - // 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); - - // Update zoom in view model - final zoomRatio = _pdfController.currentZoom; - _viewModel?.updateZoom(zoomRatio); - } } - 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 _onOutlineSelected(PdfOutlineNode outline) { + if (outline.dest == null) return; + _pdfController.goToPage(pageNumber: outline.dest!.pageNumber); } - void _onLayoutSelected(BaseLayout layout) { + void _onFigureSelected(BaseLayout layout) { // Navigate to the page containing this figure if (_viewModel != null && layout.type == LayoutType.figure) { final pageNumber = _viewModel!.getPageNumberForFigure(layout); @@ -82,9 +67,14 @@ class _PDFViewerState extends State { } } + void _onSidebarToggle() { + setState(() { + _sidebarVisible = !_sidebarVisible; + }); + } + @override void dispose() { - _pdfController.removeListener(_onPdfControllerChanged); _viewModel?.dispose(); super.dispose(); } @@ -106,7 +96,7 @@ class _PDFViewerState extends State { ); } // 2. PDF 미로딩 상태 - if (_viewModel!.errorMessage != null) { + if (_viewModel!.isFailed) { return const Scaffold(body: NoPdfLoadedView()); } // 3. 정상 상태 @@ -126,18 +116,22 @@ class _PDFViewerState extends State { backgroundColor: Theme.of(context).colorScheme.surfaceContainer, pageOverlaysBuilder: (context, pageRect, page) { - return _buildPageOverlays(context, pageRect, page); + return _buildReferenceHighlights( + context, + pageRect, + page.pageNumber, + ); }, ), ), ), // Bottom Status Bar PDFBottomBar( - zoomPercent: _viewModel!.zoomPercent.toInt(), - currentPage: _viewModel!.currentPage, + zoomPercent: (_pdfController.currentZoom * 100).toInt(), + currentPage: _pdfController.pageNumber ?? 1, totalPages: _viewModel!.pages.length, - sidebarVisible: _viewModel!.sidebarVisible, - onSidebarToggle: _viewModel!.toggleSidebar, + sidebarVisible: _sidebarVisible, + onSidebarToggle: _onSidebarToggle, ), ], ), @@ -158,12 +152,11 @@ class _PDFViewerState extends State { ); }, child: - _viewModel!.sidebarVisible - ? PdfSidebar( - key: const ValueKey('sidebar'), + _sidebarVisible + ? PDFSideBar( viewModel: _viewModel!, - onPageSelected: _onPageSelected, - onLayoutSelected: _onLayoutSelected, + onPageSelected: _onOutlineSelected, + onFigureSelected: _onFigureSelected, ) : const SizedBox.shrink(key: ValueKey('empty')), ), @@ -174,39 +167,21 @@ class _PDFViewerState extends State { ); } - List _buildPageOverlays( + // 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, + final layouts = _viewModel!.layoutsByPage[pageNumber]; + final page = _viewModel!.pages[pageNumber]; + if (layouts == null) return []; + final references = layouts.where( + (layout) => layout.type == LayoutType.figureReference, ); - - // Get figure references for this page - final figureReferences = _viewModel!.getFigureReferencesForPage(pageIndex); - - if (figureReferences.isEmpty) { - return []; - } - - // scaleX, scaleY 각각 적용 - final scaleX = pageRect.width / pageModel.width.toDouble(); - final scaleY = pageRect.height / pageModel.height.toDouble(); - - return figureReferences.map((reference) { + final scaleX = pageRect.width / page.width.toDouble(); + final scaleY = pageRect.height / page.height.toDouble(); + return references.map((reference) { final rect = reference.rect; // Direct coordinate transformation without Y-axis flip @@ -223,29 +198,16 @@ class _PDFViewerState extends State { height: height, child: Builder( builder: (innerContext) { + final theme = Theme.of(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); + _onFigureSelected(reference); }, child: Container( decoration: BoxDecoration( - border: Border.all( - color: Theme.of( - context, - ).colorScheme.secondary.withOpacity(0.7), - width: 2, + color: theme.colorScheme.secondaryContainer.withValues( + alpha: 0.2, ), - borderRadius: BorderRadius.circular(4), - color: Theme.of( - context, - ).colorScheme.secondary.withOpacity(0.1), ), ), ); @@ -254,466 +216,4 @@ class _PDFViewerState extends State { ); }).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 _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/document_card.dart b/lib/features/pdf_viewer/widgets/document_card.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/features/pdf_viewer/widgets/document_card.dart +++ /dev/null @@ -1 +0,0 @@ - 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..b8b8fd1 --- /dev/null +++ b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:snapfig/features/pdf_viewer/models/pdf_viewer_viewmodel.dart'; +import 'package:snapfig/shared/services/pdf_core/models/models.dart'; + +class FigureOverlayWidget extends StatelessWidget { + final BaseLayout figure; + final PDFDataViewModel viewModel; + + const FigureOverlayWidget({ + super.key, + required this.figure, + required this.viewModel, + }); + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} 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/pdf_sidebar.dart b/lib/features/pdf_viewer/widgets/pdf_sidebar.dart deleted file mode 100644 index e047969..0000000 --- a/lib/features/pdf_viewer/widgets/pdf_sidebar.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../../shared/services/pdf_core/pdf_core.dart'; -import '../models/pdf_viewer_view_model.dart'; - -class PdfSidebar extends StatelessWidget { - final PdfViewerViewModel viewModel; - final Function(BasePage) onPageSelected; - final Function(BaseLayout) onLayoutSelected; - - const PdfSidebar({ - super.key, - required this.viewModel, - required this.onPageSelected, - required this.onLayoutSelected, - }); - - @override - Widget build(BuildContext context) { - return Container( - width: 300, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border(left: BorderSide(color: Theme.of(context).dividerColor)), - ), - child: Column( - children: [ - // Tab Toggle - _buildTabToggle(context), - - // Content List - Expanded( - child: - viewModel.selectedTabIndex == 0 - ? _PageListView( - pages: viewModel.filteredPages, - onPageSelected: onPageSelected, - ) - : _LayoutListView( - layouts: viewModel.filteredLayouts, - onLayoutSelected: onLayoutSelected, - ), - ), - - // Search Bar - _buildSearchBar(context), - ], - ), - ); - } - - Widget _buildTabToggle(BuildContext context) { - return ToggleButtons( - isSelected: [ - viewModel.selectedTabIndex == 0, - viewModel.selectedTabIndex == 1, - ], - onPressed: viewModel.selectTab, - borderRadius: BorderRadius.circular(20), - selectedBorderColor: Theme.of(context).colorScheme.onSecondaryContainer, - fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - selectedColor: Theme.of(context).colorScheme.onSecondaryContainer, - color: Theme.of(context).colorScheme.onSurface, - constraints: const BoxConstraints(minWidth: 100, minHeight: 36), - children: [ - Text('Page', style: Theme.of(context).textTheme.bodyMedium), - Text('Figure', style: Theme.of(context).textTheme.bodyMedium), - ], - ); - } - - Widget _buildSearchBar(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - decoration: InputDecoration( - hintText: - viewModel.selectedTabIndex == 0 ? 'Search Page' : 'Search Figure', - prefixIcon: const Icon(Icons.search), - suffixIcon: - viewModel.searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: viewModel.clearSearch, - ) - : null, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - isDense: true, - contentPadding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 12, - ), - ), - onChanged: viewModel.updateSearchQuery, - ), - ); - } -} - -class _PageListView extends StatelessWidget { - final List pages; - final Function(BasePage) onPageSelected; - - const _PageListView({required this.pages, required this.onPageSelected}); - - @override - Widget build(BuildContext context) { - return ListView.builder( - itemCount: pages.length, - itemBuilder: (context, index) { - final page = pages[index]; - return ListTile( - leading: _buildPageThumbnail(page, context: context), - title: Text('Page ${page.pageIndex}'), - trailing: const Icon(Icons.chevron_right), - onTap: () => onPageSelected(page), - ); - }, - ); - } - - Widget _buildPageThumbnail(BasePage page, {required BuildContext context}) { - // For now, use a placeholder. In a real implementation, - // you would render the page thumbnail using pdfrx - return Container( - width: 45, - height: 45, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(15), - ), - child: const Icon(Icons.picture_as_pdf, color: Colors.grey), - ); - } -} - -class _LayoutListView extends StatelessWidget { - final List layouts; - final Function(BaseLayout) onLayoutSelected; - - const _LayoutListView({ - required this.layouts, - required this.onLayoutSelected, - }); - - @override - Widget build(BuildContext context) { - final figures = - layouts.where((layout) => layout.type == LayoutType.figure).toList(); - return ListView.builder( - itemCount: figures.length, - itemBuilder: (context, index) { - final layout = figures[index]; - return ListTile( - leading: _buildLayoutThumbnail(layout, context: context), - title: Text(layout.figureId ?? 'Figure ${index + 1}'), - trailing: const Icon(Icons.chevron_right), - onTap: () => onLayoutSelected(layout), - ); - }, - ); - } - - Widget _buildLayoutThumbnail( - BaseLayout layout, { - required BuildContext context, - }) { - return Container( - width: 45, - height: 45, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(15), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.image, color: Colors.grey[700]), - if (layout.type == LayoutType.figure && layout.figureNumber != null) - Text( - 'Fig ${layout.figureNumber}', - style: const TextStyle(fontSize: 10, color: Colors.black54), - ), - if (layout.type == LayoutType.figureReference && - layout.figureNumber != null) - Text( - 'Ref ${layout.figureNumber}', - style: const TextStyle(fontSize: 10, color: Colors.black54), - ), - ], - ), - ); - } -} 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..44ddd41 --- /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].toString()), + 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..55da0d2 --- /dev/null +++ b/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:pdfrx/pdfrx.dart'; +import 'package:snapfig/features/pdf_viewer/models/pdf_viewer_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, + 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) + OutlineSidebar( + outlines: widget.viewModel.outlines, + onOutlineSelected: widget.onPageSelected, + ), + if (_selectedTab == _PDFSideBarTab.figure) + FigureSidebar( + figures: widget.viewModel.figures, + onFigureSelected: widget.onFigureSelected, + ), + ], + ), + ); + } +} diff --git a/lib/shared/services/navigation_service/navigation_view_model.dart b/lib/shared/services/navigation_service/navigation_view_model.dart index a991ce3..fba2a5a 100644 --- a/lib/shared/services/navigation_service/navigation_view_model.dart +++ b/lib/shared/services/navigation_service/navigation_view_model.dart @@ -15,9 +15,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/pdf_core/provider/pdf_provider_impl.dart b/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart index fbb2084..0b7f093 100644 --- a/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart +++ b/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart @@ -122,97 +122,89 @@ class PDFProviderImpl extends PDFProvider { if (ocrResult == null) throw Exception('OCR 결과 없음'); try { - 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, - ); - - await _isar.pageModels.put(pageModel); - - // 페이지와 PDF 연결 - pageModel.pdf.value = pdf; - await pageModel.pdf.save(); - pdf.pages.add(pageModel); - - // 3. 레이아웃 데이터 저장 - for (final layout in pageDetail.layouts) { - LayoutModel layoutModel; + final pages = ocrResult.paragraphData.pages; + final interactiveElements = ocrResult.interactiveElements; + + // Mapping OCRResult to PageModel and LayoutModel + final pageModels = []; + for (final page in pages) { + final interactiveElementsInPage = interactiveElements.where( + (elem) => elem.pageNum == page.pageNum, + ); + final pageModel = PageModel.create( + pageIndex: page.pageNum, + fullText: page.blocks.map((block) => block.text).join('\n'), + size: Size(0, 0), // Size information not available in OCRResult + ); - if (layout is TextLayout) { - layoutModel = LayoutModel( - 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( + final layoutModels = []; + for (final interactiveElem in interactiveElementsInPage) { + if (interactiveElem is FigureLink) { + 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); - - // 레이아웃과 페이지 연결 - layoutModel.page.value = pageModel; - await layoutModel.page.save(); - pageModel.layouts.add(layoutModel); + content: '', + text: '', + latex: '', + box: Rect.fromLTRB( + interactiveElem.referenceBbox.x0, + interactiveElem.referenceBbox.y0, + interactiveElem.referenceBbox.x1, + interactiveElem.referenceBbox.y1, + ), + referencedFigureId: interactiveElem.targetXref.toString(), + ), + ); + } else if (interactiveElem is AnnotationLink) { + layoutModels.add( + LayoutModel.create( + type: LayoutType.figure, + content: interactiveElem.targetText, + text: '', + latex: '', + box: Rect.fromLTRB( + interactiveElem.referenceBbox.x0, + interactiveElem.referenceBbox.y0, + interactiveElem.referenceBbox.x1, + interactiveElem.referenceBbox.y1, + ), + ), + ); + } else if (interactiveElem is UncaptionedImage) { + layoutModels.add( + LayoutModel.create( + type: LayoutType.figure, + content: '', + text: '', + latex: '', + box: Rect.fromLTRB( + interactiveElem.referenceBbox.x0, + interactiveElem.referenceBbox.y0, + interactiveElem.referenceBbox.x1, + interactiveElem.referenceBbox.y1, + ), + figureId: interactiveElem.xref.toString(), + ), + ); } - - await pageModel.layouts.save(); } + pageModel.layouts.addAll(layoutModels); + pageModels.add(pageModel); + } - // 4. PDF와 페이지 관계 저장 - await pdf.pages.save(); + // Save to Isar database + await _isar.writeTxn(() async { + // Save page models + await _isar.pageModels.putAll(pageModels); + + // Update PDF model with pages + pdf.pages.clear(); + pdf.pages.addAll(pageModels); + // Save updated PDF model await _isar.pDFModels.put(pdf); + await pdf.pages.save(); }); - // 5. PDF 상태를 완료로 업데이트 (updatePDF 메서드 사용) await updatePDF( id: pdf.id, status: PDFStatus.completed, From 5f469539c0502207cc0aeb54350db79d4b5c9ac6 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Wed, 11 Jun 2025 00:54:40 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20PDFDataViewModel=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PDF 파일 로딩 및 페이지 관리 기능 구현 - OCR 결과 처리 및 페이지별 레이아웃 관리 기능 추가 - 피규어 및 참조에 대한 메서드 추가 - 비동기 이미지 렌더링 기능 포함 --- ...ewer_viewmodel.dart => pdf_data_viewmodel.dart} | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) rename lib/features/pdf_viewer/models/{pdf_viewer_viewmodel.dart => pdf_data_viewmodel.dart} (89%) diff --git a/lib/features/pdf_viewer/models/pdf_viewer_viewmodel.dart b/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart similarity index 89% rename from lib/features/pdf_viewer/models/pdf_viewer_viewmodel.dart rename to lib/features/pdf_viewer/models/pdf_data_viewmodel.dart index 6a3fca6..438c566 100644 --- a/lib/features/pdf_viewer/models/pdf_viewer_viewmodel.dart +++ b/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart @@ -2,6 +2,7 @@ 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; @@ -78,6 +79,13 @@ class PDFDataViewModel extends ChangeNotifier { ?.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; @@ -87,7 +95,7 @@ class PDFDataViewModel extends ChangeNotifier { .firstWhereOrNull((layout) => layout.figureId == referencedId); } - Future getFigureImage(BaseLayout figure) async { + Future getFigureImage(BaseLayout figure) async { if (figure.type != LayoutType.figure) return null; final pageIndex = getPageNumberForFigure(figure); if (pageIndex == null) return null; @@ -110,6 +118,8 @@ class PDFDataViewModel extends ChangeNotifier { width: width.toInt(), height: height.toInt(), ); - return image; + if (image == null) return null; + final imageData = await image.createImage(); + return imageData; } } From db57550f6c288781864522868bf4720b942679b2 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Wed, 11 Jun 2025 00:55:07 +0900 Subject: [PATCH 05/17] =?UTF-8?q?fix:=20OCRProviderImpl=20API=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20-=20OCRProviderImp?= =?UTF-8?q?l=EC=97=90=EC=84=9C=20API=20=EC=9A=94=EC=B2=AD=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20'/api/v1/process'=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20-=20OCR=20API=20=EC=9D=91=EB=8B=B5=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=BD=94=EB=93=9C=EB=A5=BC=20202=EC=97=90=EC=84=9C?= =?UTF-8?q?=20200=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=ED=95=98=EC=97=AC?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EC=8B=A4=ED=8C=A8=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20-=20PageData=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=EC=97=90=EC=84=9C=20pageNum=EC=9D=84=20num?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=95=88=EC=A0=95=EC=84=B1=20=ED=96=A5?= =?UTF-8?q?=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/shared/services/ocr_core/models/ocr_result.dart | 2 +- lib/shared/services/ocr_core/ocr_provider_impl.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/shared/services/ocr_core/models/ocr_result.dart b/lib/shared/services/ocr_core/models/ocr_result.dart index 7a786dd..201a02f 100644 --- a/lib/shared/services/ocr_core/models/ocr_result.dart +++ b/lib/shared/services/ocr_core/models/ocr_result.dart @@ -25,7 +25,7 @@ class PageData { factory PageData.fromJson(Map json) { return PageData( - pageNum: json['page_num'] as int, + pageNum: (json['page_num'] as num).toInt(), blocks: (json['blocks'] as List) .map((block) => TextBlock.fromJson(block as Map)) diff --git a/lib/shared/services/ocr_core/ocr_provider_impl.dart b/lib/shared/services/ocr_core/ocr_provider_impl.dart index a68e9c5..f349be9 100644 --- a/lib/shared/services/ocr_core/ocr_provider_impl.dart +++ b/lib/shared/services/ocr_core/ocr_provider_impl.dart @@ -20,7 +20,7 @@ class OCRProviderImpl extends OCRProvider { @override Future process(String pdfPath) async { // 1) 분석 요청 - final analyzeUri = Uri.parse('$baseUrl/process'); + final analyzeUri = Uri.parse('$baseUrl/api/v1/process'); final pdfBytes = await File(pdfPath).readAsBytes(); final request = http.MultipartRequest('POST', analyzeUri) ..files.add( @@ -32,7 +32,7 @@ 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}'); } final bodyJson = json.decode(analyzeRes.body) as Map; From c6a2f5c361a888b24ce6509bbb80a3bb2e387852 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Wed, 11 Jun 2025 14:21:37 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20PDF=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=94=BC?= =?UTF-8?q?=EA=B7=9C=EC=96=B4=20=ED=8C=9D=EC=97=85=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20PDFDataViewModel=EC=97=90=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20-=20PDF=20=EB=B7=B0?= =?UTF-8?q?=EC=96=B4=EC=97=90=EC=84=9C=20=ED=94=BC=EA=B7=9C=EC=96=B4=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=ED=8C=9D=EC=97=85=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=91=9C=EC=8B=9C=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20-=20FigureOverlayWidget?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=B4=20=ED=94=BC=EA=B7=9C=EC=96=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B0=8F=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20-=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=EC=97=90=EC=84=9C=20=ED=94=BC?= =?UTF-8?q?=EA=B7=9C=EC=96=B4=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=8B=9C=20UI=20=EA=B0=9C=EC=84=A0=20-=20?= =?UTF-8?q?PDF=20=EB=B7=B0=EC=96=B4=EC=9D=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EB=A1=9C=EB=94=A9=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pdf_viewer/models/pdf_data_viewmodel.dart | 14 +- .../pdf_viewer/screens/pdf_viewer.dart | 231 ++++++++++++++---- .../widgets/figure_overlay_widget.dart | 63 ++++- .../pdf_viewer/widgets/popover_wrapper.dart | 133 ++++++++++ .../widgets/sidebar/figure_sidebar.dart | 2 +- .../widgets/sidebar/pdf_sidebar.dart | 18 +- .../ocr_core/models/annotation_link.dart | 22 +- .../services/ocr_core/models/figure_link.dart | 22 +- .../services/ocr_core/models/ocr_result.dart | 54 ++-- .../ocr_core/models/uncaptioned_image.dart | 22 +- .../services/ocr_core/ocr_provider_impl.dart | 9 +- .../pdf_core/provider/pdf_provider_impl.dart | 108 +++++--- 12 files changed, 542 insertions(+), 156 deletions(-) create mode 100644 lib/features/pdf_viewer/widgets/popover_wrapper.dart diff --git a/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart b/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart index 438c566..3143050 100644 --- a/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart +++ b/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart @@ -12,11 +12,13 @@ class PDFDataViewModel extends ChangeNotifier { 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; @@ -35,14 +37,14 @@ class PDFDataViewModel extends ChangeNotifier { @override void dispose() { - _pdfDocument?.dispose(); super.dispose(); } - void loadPDF(String path) async { + 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); @@ -54,8 +56,7 @@ class PDFDataViewModel extends ChangeNotifier { } _pages = await pdfModel.getPages(); _pdfModel = pdfModel; - _pdfDocument = await PdfDocument.openFile(path); - _outlines = await _pdfDocument?.loadOutline() ?? []; + _outlines = await _pdfDocument!.loadOutline(); _layoutsByPage = await Future.wait( _pages.map((page) async { final layouts = await page.getLayouts(); @@ -69,6 +70,7 @@ class PDFDataViewModel extends ChangeNotifier { _errorMessage = e.toString(); } _isLoading = false; + _isDataLoaded = true; notifyListeners(); } @@ -96,7 +98,7 @@ class PDFDataViewModel extends ChangeNotifier { } Future getFigureImage(BaseLayout figure) async { - if (figure.type != LayoutType.figure) return null; + if (figure.type != LayoutType.figure || _pdfDocument == null) return null; final pageIndex = getPageNumberForFigure(figure); if (pageIndex == null) return null; final pageDoc = _pdfDocument!.pages.firstWhereOrNull( diff --git a/lib/features/pdf_viewer/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index cac7ef5..80b3b79 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -1,19 +1,13 @@ import 'package:flutter/material.dart'; import 'package:pdfrx/pdfrx.dart'; -import 'package:snapfig/features/pdf_viewer/models/pdf_viewer_viewmodel.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 '../../../shared/services/pdf_core/pdf_core.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; @@ -38,9 +32,13 @@ class _PDFViewerState extends State { setState(() { _viewModel = PDFDataViewModel(pdfProvider: pdfProvider); }); - - // Load initial PDF - _viewModel!.loadPDF(widget.path); + _pdfController.addListener(() { + if (_pdfController.isReady) { + _pdfController.useDocument((doc) { + _viewModel?.loadPDFData(widget.path, doc); + }); + } + }); }); } @@ -50,20 +48,71 @@ class _PDFViewerState extends State { } void _onFigureSelected(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), + // 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 _showFigurePopover(BaseLayout reference) { + final RenderBox? renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + // Get the position where the tap occurred + final overlay = Overlay.of(context); + final pageNumber = _viewModel!.getPageNumberForReference(reference); + if (pageNumber == null) return; + + // Calculate position based on the reference location + final page = _viewModel!.pages[pageNumber]; + final pageRect = Rect.fromLTWH( + 0, + 0, + page.width.toDouble(), + page.height.toDouble(), + ); // TODO: FIXXXX + if (pageRect == null) return; + + final scaleX = pageRect.width / page.width.toDouble(); + final scaleY = pageRect.height / page.height.toDouble(); + + final referenceCenter = Offset( + pageRect.left + (reference.rect.center.dx * scaleX), + pageRect.top + (reference.rect.center.dy * scaleY), + ); + + final overlayEntry = OverlayEntry( + builder: + (context) => PopoverWrapper( + targetPosition: referenceCenter, + onDismiss: () {}, + child: FigureOverlayWidget( + reference: reference, + viewModel: _viewModel!, + onClose: () {}, + navigateToFigure: (figure) { + _navigateToFigure(figure); + }, + ), ), - ); - } + ); + + overlay.insert(overlayEntry); + } + + void _navigateToFigure(BaseLayout figure) { + final pageNumber = _viewModel!.getPageNumberForFigure(figure); + 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), + ), + ); } } @@ -89,17 +138,6 @@ 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!.isFailed) { - return const Scaffold(body: NoPdfLoadedView()); - } - // 3. 정상 상태 return Scaffold( appBar: AppBar(title: Text(_viewModel!.pdfTitle), actions: []), body: Row( @@ -116,6 +154,7 @@ class _PDFViewerState extends State { backgroundColor: Theme.of(context).colorScheme.surfaceContainer, pageOverlaysBuilder: (context, pageRect, page) { + print('pageOverlaysBuilder: ${page.pageNumber}'); return _buildReferenceHighlights( context, pageRect, @@ -125,14 +164,15 @@ class _PDFViewerState extends State { ), ), ), - // Bottom Status Bar - PDFBottomBar( - zoomPercent: (_pdfController.currentZoom * 100).toInt(), - currentPage: _pdfController.pageNumber ?? 1, - totalPages: _viewModel!.pages.length, - sidebarVisible: _sidebarVisible, - onSidebarToggle: _onSidebarToggle, - ), + if (_pdfController.isReady) + // Bottom Status Bar + PDFBottomBar( + zoomPercent: (_pdfController.currentZoom * 100).toInt(), + currentPage: _pdfController.pageNumber ?? 1, + totalPages: _viewModel!.pages.length, + sidebarVisible: _sidebarVisible, + onSidebarToggle: _onSidebarToggle, + ), ], ), ), @@ -153,11 +193,7 @@ class _PDFViewerState extends State { }, child: _sidebarVisible - ? PDFSideBar( - viewModel: _viewModel!, - onPageSelected: _onOutlineSelected, - onFigureSelected: _onFigureSelected, - ) + ? _buildSideBar(context) : const SizedBox.shrink(key: ValueKey('empty')), ), ], @@ -167,20 +203,67 @@ class _PDFViewerState extends State { ); } + 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, int pageNumber, ) { - final layouts = _viewModel!.layoutsByPage[pageNumber]; - final page = _viewModel!.pages[pageNumber]; + if (_viewModel == null || _viewModel!.isLoading) return []; + final pageIndex = pageNumber - 1; + final layouts = _viewModel!.layoutsByPage[pageIndex]; + final page = _viewModel!.pages[pageIndex]; if (layouts == null) return []; final references = layouts.where( - (layout) => layout.type == LayoutType.figureReference, + (layout) => layout.type == LayoutType.figure, ); final scaleX = pageRect.width / page.width.toDouble(); final scaleY = pageRect.height / page.height.toDouble(); + print('Reference highlights: ${references.length}'); + return references.map((reference) { final rect = reference.rect; @@ -191,6 +274,28 @@ class _PDFViewerState extends State { final width = rect.width * scaleX; final height = rect.height * scaleY; + // 디버깅용 출력 - 좌표와 크기 확인 + print('Figure highlight - page: $pageNumber'); + print(' Original rect: ${rect.toString()}'); + print(' Scaled: left=$left, top=$top, width=$width, height=$height'); + print(' PageRect: ${pageRect.toString()}'); + print(' Page size: ${page.width}x${page.height}'); + print(' Scale factors: X=$scaleX, Y=$scaleY'); + + // width, height가 유효한지 확인 + if (width <= 0 || height <= 0) { + print(' ❌ Invalid dimensions: width=$width, height=$height'); + return const SizedBox.shrink(); + } + + // 페이지 범위를 벗어나는지 확인 + if (left < 0 || + top < 0 || + left + width > pageRect.width || + top + height > pageRect.height) { + print(' ⚠️ Highlight outside page bounds'); + } + return Positioned( left: left, top: top, @@ -205,8 +310,26 @@ class _PDFViewerState extends State { }, child: Container( decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer.withValues( - alpha: 0.2, + // 더 명확하게 보이도록 배경색과 경계선 추가 + color: theme.colorScheme.primary.withValues(alpha: 0.2), + border: Border.all( + color: theme.colorScheme.primary, + width: 2.0, + ), + borderRadius: BorderRadius.circular(4.0), + ), + child: Center( + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(2), + ), + child: Icon( + Icons.image, + size: 12, + color: theme.colorScheme.onPrimary, + ), ), ), ), diff --git a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart index b8b8fd1..1ab527d 100644 --- a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart +++ b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart @@ -1,19 +1,74 @@ import 'package:flutter/material.dart'; -import 'package:snapfig/features/pdf_viewer/models/pdf_viewer_viewmodel.dart'; +import 'package:pdfrx/pdfrx.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 StatelessWidget { - final BaseLayout figure; + final BaseLayout reference; final PDFDataViewModel viewModel; + final VoidCallback onClose; + final void Function(BaseLayout figure)? navigateToFigure; const FigureOverlayWidget({ super.key, - required this.figure, + required this.reference, required this.viewModel, + required this.onClose, + this.navigateToFigure, }); @override Widget build(BuildContext context) { - return const Placeholder(); + // Find the actual figure that this reference points to + final targetFigure = viewModel.findFigureByReference(reference); + if (targetFigure == null) return _buildNotFoundContent(context); + + return FutureBuilder( + future: viewModel.getFigureImage(targetFigure), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + return Container( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: RawImage(image: snapshot.data!), + ), + TextButton( + onPressed: () { + navigateToFigure?.call(targetFigure); + }, + child: const Text('Go to figure'), + ), + ], + ), + ); + } else { + return _buildNotFoundContent(context); + } + }, + ); + } + + Widget _buildNotFoundContent(BuildContext context) { + final theme = Theme.of(context); + + return Column( + children: [ + Icon( + Icons.image_not_supported, + size: 48, + color: theme.colorScheme.outline, + ), + const SizedBox(height: 16), + Text('Figure not found', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'The referenced figure could not be located in the document.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ); } } 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..cfa0577 --- /dev/null +++ b/lib/features/pdf_viewer/widgets/popover_wrapper.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +class PopoverWrapper extends StatelessWidget { + final Offset targetPosition; + final Widget child; + final VoidCallback? onDismiss; + + const PopoverWrapper({ + super.key, + required this.targetPosition, + required this.child, + this.onDismiss, + }); + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + final theme = Theme.of(context); + + // Calculate popover position + const popoverWidth = 280.0; + const popoverHeight = 200.0; + const arrowSize = 8.0; + + // Determine if popover should appear above or below the target + final showAbove = targetPosition.dy > screenSize.height / 2; + + // Calculate horizontal position (center on target, but keep within screen bounds) + double left = targetPosition.dx - popoverWidth / 2; + if (left < 16) left = 16; + if (left + popoverWidth > screenSize.width - 16) { + left = screenSize.width - popoverWidth - 16; + } + + // Calculate vertical position + final top = showAbove + ? targetPosition.dy - popoverHeight - arrowSize - 8 + : targetPosition.dy + arrowSize + 8; + + // Calculate arrow position relative to popover + final arrowLeft = (targetPosition.dx - left).clamp(arrowSize, popoverWidth - arrowSize); + + 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, + height: popoverHeight, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 20, + offset: const Offset(0, 8), + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: child, + ), + ), + ), + ), + // Arrow + Positioned( + left: left + arrowLeft - arrowSize, + top: showAbove ? top + popoverHeight : top - arrowSize, + child: CustomPaint( + size: Size(arrowSize * 2, arrowSize), + painter: _ArrowPainter( + color: theme.colorScheme.surface, + pointsUp: !showAbove, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ArrowPainter extends CustomPainter { + final Color color; + final bool pointsUp; + + _ArrowPainter({required this.color, required this.pointsUp}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + 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, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} \ No newline at end of file diff --git a/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart b/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart index 44ddd41..d42eac4 100644 --- a/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart +++ b/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart @@ -18,7 +18,7 @@ class FigureSidebar extends StatelessWidget { itemBuilder: (context, index) { return ListTile( leading: _buildThumbnail(figures[index], context: context), - title: Text(figures[index].toString()), + title: Text(figures[index].figureId ?? ''), trailing: const Icon(Icons.chevron_right), onTap: () => onFigureSelected(figures[index]), ); diff --git a/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart b/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart index 55da0d2..36ea506 100644 --- a/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart +++ b/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:pdfrx/pdfrx.dart'; -import 'package:snapfig/features/pdf_viewer/models/pdf_viewer_viewmodel.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'; @@ -55,14 +55,18 @@ class _PDFSideBarState extends State { }, ), if (_selectedTab == _PDFSideBarTab.page) - OutlineSidebar( - outlines: widget.viewModel.outlines, - onOutlineSelected: widget.onPageSelected, + Expanded( + child: OutlineSidebar( + outlines: widget.viewModel.outlines, + onOutlineSelected: widget.onPageSelected, + ), ), if (_selectedTab == _PDFSideBarTab.figure) - FigureSidebar( - figures: widget.viewModel.figures, - onFigureSelected: widget.onFigureSelected, + Expanded( + child: FigureSidebar( + figures: widget.viewModel.figures, + onFigureSelected: widget.onFigureSelected, + ), ), ], ), diff --git a/lib/shared/services/ocr_core/models/annotation_link.dart b/lib/shared/services/ocr_core/models/annotation_link.dart index 08942ea..3ca830b 100644 --- a/lib/shared/services/ocr_core/models/annotation_link.dart +++ b/lib/shared/services/ocr_core/models/annotation_link.dart @@ -21,15 +21,19 @@ class AnnotationLink extends InteractiveElement { } factory AnnotationLink.fromJson(Map json) { - return AnnotationLink( - pageNum: json['page_num'] as int, - referenceBbox: BoundingBox.fromJson( - json['reference_bbox'] as Map, - ), - targetText: json['target_text'] as String, - someOptionalField: - json['some_optional_field'] as String? ?? 'default_value', - ); + try { + return AnnotationLink( + pageNum: json['page_num'] as int, + referenceBbox: BoundingBox.fromJson( + json['reference_bbox'] as Map, + ), + targetText: json['target_text'] as String, + someOptionalField: + json['some_optional_field'] as String? ?? 'default_value', + ); + } catch (e) { + throw AssertionError('AnnotationLink.fromJson error: $e, $json'); + } } @override diff --git a/lib/shared/services/ocr_core/models/figure_link.dart b/lib/shared/services/ocr_core/models/figure_link.dart index b0a01fa..9628362 100644 --- a/lib/shared/services/ocr_core/models/figure_link.dart +++ b/lib/shared/services/ocr_core/models/figure_link.dart @@ -24,15 +24,19 @@ class FigureLink extends InteractiveElement { } factory FigureLink.fromJson(Map json) { - return FigureLink( - pageNum: json['page_num'] as int, - referenceBbox: BoundingBox.fromJson( - json['reference_bbox'] as Map, - ), - targetXref: json['target_xref'] as int, - pdfFilename: json['pdf_filename'] as String, - elementType: json['element_type'] as String? ?? 'figure', - ); + try { + return FigureLink( + pageNum: json['page_num'] as int, + referenceBbox: BoundingBox.fromJson( + json['reference_bbox'] as Map, + ), + targetXref: json['target_xref'] as int, + pdfFilename: json['pdf_filename'] as String, + elementType: json['element_type'] as String? ?? 'figure', + ); + } catch (e) { + throw AssertionError('FigureLink.fromJson error: $e, $json'); + } } @override diff --git a/lib/shared/services/ocr_core/models/ocr_result.dart b/lib/shared/services/ocr_core/models/ocr_result.dart index 201a02f..34203b5 100644 --- a/lib/shared/services/ocr_core/models/ocr_result.dart +++ b/lib/shared/services/ocr_core/models/ocr_result.dart @@ -1,34 +1,63 @@ +import 'package:flutter/widgets.dart'; import 'package:snapfig/shared/services/ocr_core/models/annotation_link.dart'; +import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; import 'package:snapfig/shared/services/ocr_core/models/figure_link.dart'; import 'package:snapfig/shared/services/ocr_core/models/interactive_element.dart'; import 'package:snapfig/shared/services/ocr_core/models/uncaptioned_image.dart'; class TextBlock { final String text; + final Rect bbox; - const TextBlock({required this.text}); + const TextBlock({required this.text, required this.bbox}); factory TextBlock.fromJson(Map json) { - return TextBlock(text: json['text'] as String); + final bbox = json['bbox'] as List; + return TextBlock( + text: json['text'] as String, + bbox: Rect.fromLTWH( + bbox[0].toDouble(), + bbox[1].toDouble(), + bbox[2].toDouble(), + bbox[3].toDouble(), + ), + ); } Map toJson() { - return {'text': text}; + return { + 'text': text, + 'bbox': [bbox.left, bbox.top, bbox.right, bbox.bottom], + }; } } class PageData { final int pageNum; final List blocks; + final Size size; - const PageData({required this.pageNum, required this.blocks}); + const PageData({ + required this.pageNum, + required this.blocks, + required this.size, + }); factory PageData.fromJson(Map json) { + final size = json['page_size'] as List; return PageData( - pageNum: (json['page_num'] as num).toInt(), + pageNum: (json['page_number'] as num).toInt(), + size: Size(size[0].toDouble(), size[1].toDouble()), blocks: (json['blocks'] as List) - .map((block) => TextBlock.fromJson(block as Map)) + .map((block) { + final blockMap = block as Map; + if (blockMap['type'] == 'text') { + return TextBlock.fromJson(blockMap); + } + return null; + }) + .whereType() .toList(), ); } @@ -74,7 +103,6 @@ class OCRResult { final elements = elementsJson.map((element) { final elementMap = element as Map; - // Determine the type based on element_type field or presence of specific fields if (elementMap.containsKey('element_type')) { switch (elementMap['element_type']) { @@ -82,18 +110,10 @@ class OCRResult { return FigureLink.fromJson(elementMap); case 'uncaptioned_image': return UncaptionedImage.fromJson(elementMap); + case 'annotation_link': + return AnnotationLink.fromJson(elementMap); } } - - // Check for type-specific fields - if (elementMap.containsKey('target_text')) { - return AnnotationLink.fromJson(elementMap); - } else if (elementMap.containsKey('target_xref')) { - return FigureLink.fromJson(elementMap); - } else if (elementMap.containsKey('xref')) { - return UncaptionedImage.fromJson(elementMap); - } - throw Exception('Unknown interactive element type: $elementMap'); }).toList(); diff --git a/lib/shared/services/ocr_core/models/uncaptioned_image.dart b/lib/shared/services/ocr_core/models/uncaptioned_image.dart index 8cb68f8..5fabc3f 100644 --- a/lib/shared/services/ocr_core/models/uncaptioned_image.dart +++ b/lib/shared/services/ocr_core/models/uncaptioned_image.dart @@ -24,15 +24,19 @@ class UncaptionedImage extends InteractiveElement { } factory UncaptionedImage.fromJson(Map json) { - return UncaptionedImage( - pageNum: json['page_num'] as int, - referenceBbox: BoundingBox.fromJson( - json['reference_bbox'] as Map, - ), - xref: json['xref'] as int, - pdfFilename: json['pdf_filename'] as String, - elementType: json['element_type'] as String? ?? 'uncaptioned_image', - ); + try { + return UncaptionedImage( + pageNum: json['page_num'] as int, + referenceBbox: BoundingBox.fromJson( + json['reference_bbox'] as Map, + ), + xref: json['xref'] as int, + pdfFilename: json['pdf_filename'] as String, + elementType: json['element_type'] as String? ?? 'uncaptioned_image', + ); + } catch (e) { + throw AssertionError('UncaptionedImage.fromJson error: $e, $json'); + } } @override diff --git a/lib/shared/services/ocr_core/ocr_provider_impl.dart b/lib/shared/services/ocr_core/ocr_provider_impl.dart index f349be9..440a468 100644 --- a/lib/shared/services/ocr_core/ocr_provider_impl.dart +++ b/lib/shared/services/ocr_core/ocr_provider_impl.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'dart:io'; 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/models/ocr_result.dart'; @@ -15,7 +16,13 @@ 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 { 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 0b7f093..48e5080 100644 --- a/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart +++ b/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart @@ -125,23 +125,42 @@ class PDFProviderImpl extends PDFProvider { final pages = ocrResult.paragraphData.pages; final interactiveElements = ocrResult.interactiveElements; - // Mapping OCRResult to PageModel and LayoutModel - final pageModels = []; - for (final page in pages) { - final interactiveElementsInPage = interactiveElements.where( - (elem) => elem.pageNum == page.pageNum, - ); - final pageModel = PageModel.create( - pageIndex: page.pageNum, - fullText: page.blocks.map((block) => block.text).join('\n'), - size: Size(0, 0), // Size information not available in OCRResult - ); + // Save to Isar database + await _isar.writeTxn(() async { + // Mapping OCRResult to PageModel and LayoutModel + final pageModels = []; + final allLayoutModels = []; + + for (final page in pages) { + final interactiveElementsInPage = interactiveElements.where( + (elem) => elem.pageNum == page.pageNum, + ); + + final pageModel = PageModel.create( + pageIndex: page.pageNum, + fullText: page.blocks.map((block) => block.text).join('\n'), + size: page.size, + ); + + // PDF와 PageModel 관계 설정 + pageModel.pdf.value = pdf; - final layoutModels = []; - for (final interactiveElem in interactiveElementsInPage) { - if (interactiveElem is FigureLink) { - layoutModels.add( - LayoutModel.create( + final layoutModels = []; + for (final textBlock in page.blocks) { + final layoutModel = LayoutModel.create( + type: LayoutType.text, + content: textBlock.text, + text: textBlock.text, + latex: '', + box: textBlock.bbox, + ); + layoutModels.add(layoutModel); + } + + for (final interactiveElem in interactiveElementsInPage) { + LayoutModel? layoutModel; + if (interactiveElem is FigureLink) { + layoutModel = LayoutModel.create( type: LayoutType.figureReference, content: '', text: '', @@ -153,11 +172,9 @@ class PDFProviderImpl extends PDFProvider { interactiveElem.referenceBbox.y1, ), referencedFigureId: interactiveElem.targetXref.toString(), - ), - ); - } else if (interactiveElem is AnnotationLink) { - layoutModels.add( - LayoutModel.create( + ); + } else if (interactiveElem is AnnotationLink) { + layoutModel = LayoutModel.create( type: LayoutType.figure, content: interactiveElem.targetText, text: '', @@ -168,11 +185,9 @@ class PDFProviderImpl extends PDFProvider { interactiveElem.referenceBbox.x1, interactiveElem.referenceBbox.y1, ), - ), - ); - } else if (interactiveElem is UncaptionedImage) { - layoutModels.add( - LayoutModel.create( + ); + } else if (interactiveElem is UncaptionedImage) { + layoutModel = LayoutModel.create( type: LayoutType.figure, content: '', text: '', @@ -184,25 +199,40 @@ class PDFProviderImpl extends PDFProvider { interactiveElem.referenceBbox.y1, ), figureId: interactiveElem.xref.toString(), - ), - ); + ); + } + + if (layoutModel != null) { + layoutModels.add(layoutModel); + } + } + + // PageModel과 LayoutModel 관계 설정 + for (final layoutModel in layoutModels) { + layoutModel.page.value = pageModel; } + + allLayoutModels.addAll(layoutModels); + pageModels.add(pageModel); } - pageModel.layouts.addAll(layoutModels); - pageModels.add(pageModel); - } - // Save to Isar database - await _isar.writeTxn(() async { - // Save page models + // 1. 먼저 PageModel들을 저장 await _isar.pageModels.putAll(pageModels); - // Update PDF model with pages - pdf.pages.clear(); - pdf.pages.addAll(pageModels); - // Save updated PDF model + // 2. LayoutModel들을 저장 + await _isar.layoutModels.putAll(allLayoutModels); + + // 3. PDF 모델 저장 및 관계 설정 await _isar.pDFModels.put(pdf); - await pdf.pages.save(); + + // 4. 관계 링크들을 저장 + for (final pageModel in pageModels) { + await pageModel.pdf.save(); + } + + for (final layoutModel in allLayoutModels) { + await layoutModel.page.save(); + } }); await updatePDF( From 333804157d7e78cfbf9596b2663c9a1db72a889c Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 04:40:54 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20OCR=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=B0=8F=20API=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20OCRProviderImpl=EC=97=90=EC=84=9C=20AP?= =?UTF-8?q?I=20=EC=9A=94=EC=B2=AD=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20'/api/v1?= =?UTF-8?q?/analyze'=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20-=20AnnotationLink,?= =?UTF-8?q?=20FigureLink,=20UncaptionedImage,=20InteractiveElement=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=82=AD=EC=A0=9C=20-=20BoundingBox=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=EC=9D=84=20BBox=EB=A1=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=ED=95=98=EC=97=AC=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20-=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20Metadata=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20OCRResult=20=EB=AA=A8=EB=8D=B8=EC=97=90=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20-=20PDFProviderImpl=EC=97=90=EC=84=9C=20OC?= =?UTF-8?q?RResult=20=EA=B5=AC=EC=A1=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20=ED=94=BC=EA=B7=9C?= =?UTF-8?q?=EC=96=B4=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ocr_core/models/annotation_link.dart | 51 ----- .../ocr_core/models/bounding_box.dart | 53 +++-- .../services/ocr_core/models/figure_link.dart | 55 ----- .../ocr_core/models/interactive_element.dart | 29 --- .../services/ocr_core/models/metadata.dart | 23 ++ .../services/ocr_core/models/models.dart | 5 +- .../services/ocr_core/models/ocr_result.dart | 200 ++++++++++-------- .../ocr_core/models/uncaptioned_image.dart | 55 ----- .../services/ocr_core/ocr_provider_impl.dart | 2 +- .../pdf_core/provider/pdf_provider_impl.dart | 106 +++++----- 10 files changed, 212 insertions(+), 367 deletions(-) delete mode 100644 lib/shared/services/ocr_core/models/annotation_link.dart delete mode 100644 lib/shared/services/ocr_core/models/figure_link.dart delete mode 100644 lib/shared/services/ocr_core/models/interactive_element.dart create mode 100644 lib/shared/services/ocr_core/models/metadata.dart delete mode 100644 lib/shared/services/ocr_core/models/uncaptioned_image.dart diff --git a/lib/shared/services/ocr_core/models/annotation_link.dart b/lib/shared/services/ocr_core/models/annotation_link.dart deleted file mode 100644 index 3ca830b..0000000 --- a/lib/shared/services/ocr_core/models/annotation_link.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; -import 'package:snapfig/shared/services/ocr_core/models/interactive_element.dart'; - -class AnnotationLink extends InteractiveElement { - final String targetText; - final String someOptionalField; - - const AnnotationLink({ - required super.pageNum, - required super.referenceBbox, - required this.targetText, - this.someOptionalField = 'default_value', - }); - - @override - Map toJson() { - final json = super.toJson(); - json['target_text'] = targetText; - json['some_optional_field'] = someOptionalField; - return json; - } - - factory AnnotationLink.fromJson(Map json) { - try { - return AnnotationLink( - pageNum: json['page_num'] as int, - referenceBbox: BoundingBox.fromJson( - json['reference_bbox'] as Map, - ), - targetText: json['target_text'] as String, - someOptionalField: - json['some_optional_field'] as String? ?? 'default_value', - ); - } catch (e) { - throw AssertionError('AnnotationLink.fromJson error: $e, $json'); - } - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is AnnotationLink && - super == other && - other.targetText == targetText && - other.someOptionalField == someOptionalField; - } - - @override - int get hashCode => - Object.hash(super.hashCode, targetText, someOptionalField); -} diff --git a/lib/shared/services/ocr_core/models/bounding_box.dart b/lib/shared/services/ocr_core/models/bounding_box.dart index e55734a..7d81aec 100644 --- a/lib/shared/services/ocr_core/models/bounding_box.dart +++ b/lib/shared/services/ocr_core/models/bounding_box.dart @@ -1,44 +1,39 @@ -class BoundingBox { - final double x0; - final double y0; - final double x1; - final double y1; +class BBox { + final double x; + final double y; + final double width; + final double height; - const BoundingBox({ - required this.x0, - required this.y0, - required this.x1, - required this.y1, + const BBox({ + required this.x, + required this.y, + required this.width, + required this.height, }); Map toJson() { - return { - 'x0': x0, - 'y0': y0, - 'x1': x1, - 'y1': y1, - }; + return {'x': x, 'y': y, 'width': width, 'height': height}; } - factory BoundingBox.fromJson(Map json) { - return BoundingBox( - x0: (json['x0'] as num).toDouble(), - y0: (json['y0'] as num).toDouble(), - x1: (json['x1'] as num).toDouble(), - y1: (json['y1'] as num).toDouble(), + 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 BoundingBox && - other.x0 == x0 && - other.y0 == y0 && - other.x1 == x1 && - other.y1 == y1; + return other is BBox && + other.x == x && + other.y == y && + other.width == width && + other.height == height; } @override - int get hashCode => Object.hash(x0, y0, x1, y1); -} \ No newline at end of file + int get hashCode => Object.hash(x, y, width, height); +} diff --git a/lib/shared/services/ocr_core/models/figure_link.dart b/lib/shared/services/ocr_core/models/figure_link.dart deleted file mode 100644 index 9628362..0000000 --- a/lib/shared/services/ocr_core/models/figure_link.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; -import 'package:snapfig/shared/services/ocr_core/models/interactive_element.dart'; - -class FigureLink extends InteractiveElement { - final int targetXref; - final String pdfFilename; - final String elementType; - - const FigureLink({ - required super.pageNum, - required super.referenceBbox, - required this.targetXref, - required this.pdfFilename, - this.elementType = 'figure', - }); - - @override - Map toJson() { - final json = super.toJson(); - json['target_xref'] = targetXref; - json['pdf_filename'] = pdfFilename; - json['element_type'] = elementType; - return json; - } - - factory FigureLink.fromJson(Map json) { - try { - return FigureLink( - pageNum: json['page_num'] as int, - referenceBbox: BoundingBox.fromJson( - json['reference_bbox'] as Map, - ), - targetXref: json['target_xref'] as int, - pdfFilename: json['pdf_filename'] as String, - elementType: json['element_type'] as String? ?? 'figure', - ); - } catch (e) { - throw AssertionError('FigureLink.fromJson error: $e, $json'); - } - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is FigureLink && - super == other && - other.targetXref == targetXref && - other.pdfFilename == pdfFilename && - other.elementType == elementType; - } - - @override - int get hashCode => - Object.hash(super.hashCode, targetXref, pdfFilename, elementType); -} diff --git a/lib/shared/services/ocr_core/models/interactive_element.dart b/lib/shared/services/ocr_core/models/interactive_element.dart deleted file mode 100644 index bb73ba9..0000000 --- a/lib/shared/services/ocr_core/models/interactive_element.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; - -abstract class InteractiveElement { - final int pageNum; - final BoundingBox referenceBbox; - - const InteractiveElement({ - required this.pageNum, - required this.referenceBbox, - }); - - Map toJson() { - return { - 'page_num': pageNum, - 'reference_bbox': referenceBbox.toJson(), - }; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is InteractiveElement && - other.pageNum == pageNum && - other.referenceBbox == referenceBbox; - } - - @override - int get hashCode => Object.hash(pageNum, referenceBbox); -} \ No newline at end of file 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 index d3f7e4b..09c21e5 100644 --- a/lib/shared/services/ocr_core/models/models.dart +++ b/lib/shared/services/ocr_core/models/models.dart @@ -1,6 +1,3 @@ -export 'annotation_link.dart'; export 'bounding_box.dart'; -export 'figure_link.dart'; -export 'interactive_element.dart'; +export 'metadata.dart'; export 'ocr_result.dart'; -export 'uncaptioned_image.dart'; diff --git a/lib/shared/services/ocr_core/models/ocr_result.dart b/lib/shared/services/ocr_core/models/ocr_result.dart index 34203b5..7a30fa2 100644 --- a/lib/shared/services/ocr_core/models/ocr_result.dart +++ b/lib/shared/services/ocr_core/models/ocr_result.dart @@ -1,135 +1,163 @@ -import 'package:flutter/widgets.dart'; -import 'package:snapfig/shared/services/ocr_core/models/annotation_link.dart'; import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; -import 'package:snapfig/shared/services/ocr_core/models/figure_link.dart'; -import 'package:snapfig/shared/services/ocr_core/models/interactive_element.dart'; -import 'package:snapfig/shared/services/ocr_core/models/uncaptioned_image.dart'; +import 'package:snapfig/shared/services/ocr_core/models/metadata.dart'; -class TextBlock { - final String text; - final Rect bbox; +class OCRResult { + final Metadata metadata; + final List pages; + final List
figures; - const TextBlock({required this.text, required this.bbox}); + const OCRResult({ + required this.metadata, + required this.pages, + required this.figures, + }); - factory TextBlock.fromJson(Map json) { - final bbox = json['bbox'] as List; - return TextBlock( - text: json['text'] as String, - bbox: Rect.fromLTWH( - bbox[0].toDouble(), - bbox[1].toDouble(), - bbox[2].toDouble(), - bbox[3].toDouble(), - ), + 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 { - 'text': text, - 'bbox': [bbox.left, bbox.top, bbox.right, bbox.bottom], + 'metadata': metadata.toJson(), + 'pages': pages.map((e) => e.toJson()).toList(), + 'figures': figures.map((e) => e.toJson()).toList(), }; } } -class PageData { - final int pageNum; - final List blocks; - final Size size; +class PageResult { + final int index; + final List pageSize; + final List blocks; + final List references; - const PageData({ - required this.pageNum, + const PageResult({ + required this.index, + required this.pageSize, required this.blocks, - required this.size, + required this.references, }); - factory PageData.fromJson(Map json) { - final size = json['page_size'] as List; - return PageData( - pageNum: (json['page_number'] as num).toInt(), - size: Size(size[0].toDouble(), size[1].toDouble()), + 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((block) { - final blockMap = block as Map; - if (blockMap['type'] == 'text') { - return TextBlock.fromJson(blockMap); - } - return null; - }) - .whereType() + .map((e) => TextResult.fromJson(e as Map)) + .toList(), + references: + (json['references'] as List) + .map((e) => Reference.fromJson(e as Map)) .toList(), ); } Map toJson() { return { - 'page_num': pageNum, - 'blocks': blocks.map((block) => block.toJson()).toList(), + 'index': index, + 'page_size': pageSize, + 'blocks': blocks.map((e) => e.toJson()).toList(), + 'references': references.map((e) => e.toJson()).toList(), }; } } -class ParagraphData { - final List pages; +class TextResult { + final String text; + final BBox bbox; - const ParagraphData({required this.pages}); + const TextResult({required this.text, required this.bbox}); - factory ParagraphData.fromJson(Map json) { - return ParagraphData( - pages: - (json['pages'] as List) - .map((page) => PageData.fromJson(page as Map)) - .toList(), + factory TextResult.fromJson(Map json) { + return TextResult( + text: json['text'] as String, + bbox: BBox.fromJson(json['bbox'] as Map), ); } Map toJson() { - return {'pages': pages.map((page) => page.toJson()).toList()}; + return {'text': text, 'bbox': bbox.toJson()}; } } -class OCRResult { - final List interactiveElements; - final ParagraphData paragraphData; - - const OCRResult({ - required this.interactiveElements, - required this.paragraphData, +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 OCRResult.fromJson(Map json) { - final elementsJson = json['interactive_elements'] as List; - final elements = - elementsJson.map((element) { - final elementMap = element as Map; - // Determine the type based on element_type field or presence of specific fields - if (elementMap.containsKey('element_type')) { - switch (elementMap['element_type']) { - case 'figure': - return FigureLink.fromJson(elementMap); - case 'uncaptioned_image': - return UncaptionedImage.fromJson(elementMap); - case 'annotation_link': - return AnnotationLink.fromJson(elementMap); - } - } - throw Exception('Unknown interactive element type: $elementMap'); - }).toList(); + 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, + ); + } - return OCRResult( - interactiveElements: elements, - paragraphData: ParagraphData.fromJson( - json['paragraph_data'] as Map, - ), + 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 { - 'interactive_elements': - interactiveElements.map((element) => element.toJson()).toList(), - 'paragraph_data': paragraphData.toJson(), + 'bbox': bbox.toJson(), + 'page_idx': pageIdx, + 'figure_id': figureId, + 'type': type, + 'text': text, }; } } diff --git a/lib/shared/services/ocr_core/models/uncaptioned_image.dart b/lib/shared/services/ocr_core/models/uncaptioned_image.dart deleted file mode 100644 index 5fabc3f..0000000 --- a/lib/shared/services/ocr_core/models/uncaptioned_image.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:snapfig/shared/services/ocr_core/models/bounding_box.dart'; -import 'package:snapfig/shared/services/ocr_core/models/interactive_element.dart'; - -class UncaptionedImage extends InteractiveElement { - final int xref; - final String pdfFilename; - final String elementType; - - const UncaptionedImage({ - required super.pageNum, - required super.referenceBbox, - required this.xref, - required this.pdfFilename, - this.elementType = 'uncaptioned_image', - }); - - @override - Map toJson() { - final json = super.toJson(); - json['xref'] = xref; - json['pdf_filename'] = pdfFilename; - json['element_type'] = elementType; - return json; - } - - factory UncaptionedImage.fromJson(Map json) { - try { - return UncaptionedImage( - pageNum: json['page_num'] as int, - referenceBbox: BoundingBox.fromJson( - json['reference_bbox'] as Map, - ), - xref: json['xref'] as int, - pdfFilename: json['pdf_filename'] as String, - elementType: json['element_type'] as String? ?? 'uncaptioned_image', - ); - } catch (e) { - throw AssertionError('UncaptionedImage.fromJson error: $e, $json'); - } - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is UncaptionedImage && - super == other && - other.xref == xref && - other.pdfFilename == pdfFilename && - other.elementType == elementType; - } - - @override - int get hashCode => - Object.hash(super.hashCode, xref, pdfFilename, elementType); -} diff --git a/lib/shared/services/ocr_core/ocr_provider_impl.dart b/lib/shared/services/ocr_core/ocr_provider_impl.dart index 440a468..bae2e02 100644 --- a/lib/shared/services/ocr_core/ocr_provider_impl.dart +++ b/lib/shared/services/ocr_core/ocr_provider_impl.dart @@ -27,7 +27,7 @@ class OCRProviderImpl extends OCRProvider { @override Future process(String pdfPath) async { // 1) 분석 요청 - final analyzeUri = Uri.parse('$baseUrl/api/v1/process'); + final analyzeUri = Uri.parse('$baseUrl/api/v1/analyze'); final pdfBytes = await File(pdfPath).readAsBytes(); final request = http.MultipartRequest('POST', analyzeUri) ..files.add( 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 48e5080..6644db1 100644 --- a/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart +++ b/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart @@ -122,8 +122,8 @@ class PDFProviderImpl extends PDFProvider { if (ocrResult == null) throw Exception('OCR 결과 없음'); try { - final pages = ocrResult.paragraphData.pages; - final interactiveElements = ocrResult.interactiveElements; + final pages = ocrResult.pages; + final figures = ocrResult.figures; // Save to Isar database await _isar.writeTxn(() async { @@ -132,79 +132,71 @@ class PDFProviderImpl extends PDFProvider { final allLayoutModels = []; for (final page in pages) { - final interactiveElementsInPage = interactiveElements.where( - (elem) => elem.pageNum == page.pageNum, - ); - final pageModel = PageModel.create( - pageIndex: page.pageNum, + pageIndex: page.index, fullText: page.blocks.map((block) => block.text).join('\n'), - size: page.size, + size: Size(page.pageSize[0], page.pageSize[1]), ); // PDF와 PageModel 관계 설정 pageModel.pdf.value = pdf; final layoutModels = []; + // 텍스트 블록 처리 for (final textBlock in page.blocks) { - final layoutModel = LayoutModel.create( - type: LayoutType.text, - content: textBlock.text, - text: textBlock.text, - latex: '', - box: textBlock.bbox, + layoutModels.add( + LayoutModel.create( + type: LayoutType.text, + content: textBlock.text, + text: textBlock.text, + latex: '', + box: Rect.fromLTWH( + textBlock.bbox.x, + textBlock.bbox.y, + textBlock.bbox.width, + textBlock.bbox.height, + ), + ), ); - layoutModels.add(layoutModel); } - for (final interactiveElem in interactiveElementsInPage) { - LayoutModel? layoutModel; - if (interactiveElem is FigureLink) { - layoutModel = LayoutModel.create( + // Reference 처리 + for (final ref in page.references) { + if (ref.notMatched) continue; + layoutModels.add( + LayoutModel.create( type: LayoutType.figureReference, - content: '', - text: '', - latex: '', - box: Rect.fromLTRB( - interactiveElem.referenceBbox.x0, - interactiveElem.referenceBbox.y0, - interactiveElem.referenceBbox.x1, - interactiveElem.referenceBbox.y1, - ), - referencedFigureId: interactiveElem.targetXref.toString(), - ); - } else if (interactiveElem is AnnotationLink) { - layoutModel = LayoutModel.create( - type: LayoutType.figure, - content: interactiveElem.targetText, - text: '', + content: ref.text, + text: ref.text, latex: '', - box: Rect.fromLTRB( - interactiveElem.referenceBbox.x0, - interactiveElem.referenceBbox.y0, - interactiveElem.referenceBbox.x1, - interactiveElem.referenceBbox.y1, + box: Rect.fromLTWH( + ref.bbox.x, + ref.bbox.y, + ref.bbox.width, + ref.bbox.height, ), - ); - } else if (interactiveElem is UncaptionedImage) { - layoutModel = LayoutModel.create( + 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: '', - text: '', + content: figure.text, + text: figure.text, latex: '', - box: Rect.fromLTRB( - interactiveElem.referenceBbox.x0, - interactiveElem.referenceBbox.y0, - interactiveElem.referenceBbox.x1, - interactiveElem.referenceBbox.y1, + box: Rect.fromLTWH( + figure.bbox.x, + figure.bbox.y, + figure.bbox.width, + figure.bbox.height, ), - figureId: interactiveElem.xref.toString(), - ); - } - - if (layoutModel != null) { - layoutModels.add(layoutModel); - } + figureId: figure.figureId, + ), + ); } // PageModel과 LayoutModel 관계 설정 From 629240555485e9c43795476e78adec13cb530cb6 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 10:59:27 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20PDF=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B9=85=20=EC=A0=95=EB=B3=B4=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20PDF=20=EB=B7=B0=EC=96=B4=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=EB=A5=BC=20=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20-=20OCR=20=EC=A2=8C=ED=91=9C=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=81=AC=EA=B8=B0?= =?UTF-8?q?=20=EB=B9=84=EC=9C=A8=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20-=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9C=84=EC=A0=AF=EC=9D=84=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EC=97=AC=20=EA=B0=80=EB=8F=85=EC=84=B1=20?= =?UTF-8?q?=ED=96=A5=EC=83=81=20-=20=EB=94=94=EB=B2=84=EA=B9=85=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EA=B0=9C=EC=84=A0:=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=A0=95=EB=B3=B4=20=EB=B0=8F=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=9D=BC=20=ED=8C=A9=ED=84=B0=20=EC=B6=94=EA=B0=80=20-=20OCRRe?= =?UTF-8?q?sult=20=EB=AA=A8=EB=8D=B8=EC=97=90=EC=84=9C=20figureId=20?= =?UTF-8?q?=EB=B0=8F=20notMatched=20=ED=95=84=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pdf_viewer/screens/pdf_viewer.dart | 111 ++++++++++-------- .../services/ocr_core/models/ocr_result.dart | 6 +- 2 files changed, 63 insertions(+), 54 deletions(-) diff --git a/lib/features/pdf_viewer/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index 80b3b79..6394cd7 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -258,42 +258,49 @@ class _PDFViewerState extends State { final page = _viewModel!.pages[pageIndex]; if (layouts == null) return []; final references = layouts.where( - (layout) => layout.type == LayoutType.figure, + (layout) => layout.type == LayoutType.text, ); + + // 페이지 크기 비율 계산 - OCR 좌표를 뷰어 좌표로 변환 final scaleX = pageRect.width / page.width.toDouble(); final scaleY = pageRect.height / page.height.toDouble(); - print('Reference highlights: ${references.length}'); + + 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 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; - // 디버깅용 출력 - 좌표와 크기 확인 - print('Figure highlight - page: $pageNumber'); - print(' Original rect: ${rect.toString()}'); + // 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()}'); - print(' Page size: ${page.width}x${page.height}'); - print(' Scale factors: X=$scaleX, Y=$scaleY'); - // width, height가 유효한지 확인 + // Basic validation if (width <= 0 || height <= 0) { - print(' ❌ Invalid dimensions: width=$width, height=$height'); + print(' ❌ Invalid dimensions, skipping'); return const SizedBox.shrink(); } - // 페이지 범위를 벗어나는지 확인 - if (left < 0 || - top < 0 || - left + width > pageRect.width || - top + height > pageRect.height) { - print(' ⚠️ Highlight outside page bounds'); + // 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( @@ -301,42 +308,44 @@ class _PDFViewerState extends State { top: top, width: width, height: height, - child: Builder( - builder: (innerContext) { - final theme = Theme.of(innerContext); - return GestureDetector( - onTap: () { - _onFigureSelected(reference); - }, + child: _buildHighlightWidget(context, reference), + ); + }).toList(); + } + + // 하이라이트 위젯을 별도 메서드로 분리 + Widget _buildHighlightWidget(BuildContext context, BaseLayout reference) { + return Builder( + builder: (innerContext) { + final theme = Theme.of(innerContext); + return GestureDetector( + onTap: () { + print('Tapped reference: ${reference.content}'); + _onFigureSelected(reference); + }, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.2), + border: Border.all(color: theme.colorScheme.primary, width: 2.0), + borderRadius: BorderRadius.circular(4.0), + ), + child: Center( child: Container( + padding: const EdgeInsets.all(2), decoration: BoxDecoration( - // 더 명확하게 보이도록 배경색과 경계선 추가 - color: theme.colorScheme.primary.withValues(alpha: 0.2), - border: Border.all( - color: theme.colorScheme.primary, - width: 2.0, - ), - borderRadius: BorderRadius.circular(4.0), + color: theme.colorScheme.primary.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(2), ), - child: Center( - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(2), - ), - child: Icon( - Icons.image, - size: 12, - color: theme.colorScheme.onPrimary, - ), - ), + child: Icon( + Icons.image, + size: 12, + color: theme.colorScheme.onPrimary, ), ), - ); - }, - ), - ); - }).toList(); + ), + ), + ); + }, + ); } } diff --git a/lib/shared/services/ocr_core/models/ocr_result.dart b/lib/shared/services/ocr_core/models/ocr_result.dart index 7a30fa2..ae0cb8c 100644 --- a/lib/shared/services/ocr_core/models/ocr_result.dart +++ b/lib/shared/services/ocr_core/models/ocr_result.dart @@ -111,8 +111,8 @@ class Reference { 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, + figureId: json['figure_id'] as String? ?? '', + notMatched: json['not_matched'] as bool? ?? false, ); } @@ -145,7 +145,7 @@ class Figure { return Figure( bbox: BBox.fromJson(json['bbox'] as Map), pageIdx: (json['page_idx'] as num).toInt(), - figureId: json['figure_id'] as String, + figureId: json['figure_id'] as String? ?? '', type: json['type'] as String, text: json['text'] as String, ); From 481c72cd953ffcc9d4ac540a1d23678b5665131a Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 11:25:15 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20PDF=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=B0=B8=EC=A1=B0=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20-=20PDF=20=EB=B7=B0=EC=96=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=ED=83=80=EC=9E=85=EC=9D=84=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20=ED=94=BC=EA=B7=9C=EC=96=B4=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20-=20?= =?UTF-8?q?=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=EC=9D=98=20=EA=B2=BD=EA=B3=84=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=8F=20=ED=8C=A8=EB=94=A9=20=EC=A1=B0=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pdf_viewer/screens/pdf_viewer.dart | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/lib/features/pdf_viewer/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index 6394cd7..cd2e022 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -258,7 +258,7 @@ class _PDFViewerState extends State { final page = _viewModel!.pages[pageIndex]; if (layouts == null) return []; final references = layouts.where( - (layout) => layout.type == LayoutType.text, + (layout) => layout.type == LayoutType.figureReference, ); // 페이지 크기 비율 계산 - OCR 좌표를 뷰어 좌표로 변환 @@ -326,22 +326,7 @@ class _PDFViewerState extends State { child: Container( decoration: BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.2), - border: Border.all(color: theme.colorScheme.primary, width: 2.0), - borderRadius: BorderRadius.circular(4.0), - ), - child: Center( - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(2), - ), - child: Icon( - Icons.image, - size: 12, - color: theme.colorScheme.onPrimary, - ), - ), + borderRadius: BorderRadius.circular(2.0), ), ), ); From 6acc9954151c763d33addf322b627f411d79a9af Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 11:37:41 +0900 Subject: [PATCH 10/17] =?UTF-8?q?refactor:=20conflict=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- integration_test/ocr_provider_test.dart | 2 +- lib/main.dart | 11 +- pubspec.lock | 652 ++---------------------- pubspec.yaml | 25 - 4 files changed, 35 insertions(+), 655 deletions(-) diff --git a/integration_test/ocr_provider_test.dart b/integration_test/ocr_provider_test.dart index 0908bfe..eef2f48 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', ); }); diff --git a/lib/main.dart b/lib/main.dart index a00d735..37e1275 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,7 +15,7 @@ void main() async { final dir = await getApplicationCacheDirectory(); final pdfProvider = await PDFProviderImpl.load( ocrProvider: OCRProviderImpl( - baseUrl: 'https://901b-39-115-116-188.ngrok-free.app', + baseUrl: 'https://ce7e-165-194-27-212.ngrok-free.app', ), dbPath: dir.path, ); @@ -31,15 +31,6 @@ class SnapfigApp extends StatelessWidget { @override Widget build(BuildContext context) { - return InheritedPDFProviderWidget( - provider: _pdfProvider, - child: MaterialApp( - title: 'Snapfig', - theme: lightTheme, - darkTheme: darkTheme, - navigatorKey: _navigationService.navigatorKey, - home: const HomeWidget(), - ), return InheritedPDFProviderWidget( provider: _pdfProvider, child: MaterialApp( diff --git a/pubspec.lock b/pubspec.lock index b5a1698..9b28281 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,30 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a - url: "https://pub.dev" - source: hosted - version: "61.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 - url: "https://pub.dev" - source: hosted - version: "5.13.0" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" _fe_analyzer_shared: dependency: transitive description: @@ -125,74 +101,10 @@ packages: dependency: transitive description: name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 - 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 + sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" url: "https://pub.dev" source: hosted - version: "8.9.5" + version: "8.10.1" characters: dependency: transitive description: @@ -209,14 +121,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.dev" - source: hosted - version: "2.0.3" clock: dependency: transitive description: @@ -233,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.10.1" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" collection: dependency: transitive description: @@ -273,30 +169,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" - url: "https://pub.dev" - source: hosted - version: "0.3.4+2" - crypto: - dependency: transitive - description: - name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.dev" - source: hosted - version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -321,22 +193,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - dartx: - dependency: transitive - description: - name: dartx - sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244" - url: "https://pub.dev" - source: hosted - version: "1.2.0" fake_async: dependency: transitive description: @@ -377,38 +233,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - ffi: - dependency: transitive - description: - name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - file_picker: - dependency: "direct main" - description: - name: file_picker - sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964" - url: "https://pub.dev" - source: hosted - version: "10.1.9" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -443,14 +267,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.28" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e - url: "https://pub.dev" - source: hosted - version: "2.0.28" flutter_test: dependency: "direct dev" description: flutter @@ -615,14 +431,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - logging: - dependency: "direct main" - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" matcher: dependency: transitive description: @@ -679,14 +487,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - path: - dependency: "direct main" - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" path: dependency: "direct main" description: @@ -747,10 +547,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: @@ -887,264 +687,56 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" - path_provider: - dependency: "direct main" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "2.1.5" - path_provider_android: + version: "1.5.0" + source_span: dependency: transitive description: - name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "2.2.17" - path_provider_foundation: + version: "1.10.1" + stack_trace: dependency: transitive description: - name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "2.4.1" - path_provider_linux: + version: "1.12.1" + stream_channel: dependency: transitive description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.2.1" - path_provider_platform_interface: + version: "2.1.4" + stream_transform: dependency: transitive description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.2" - path_provider_windows: + version: "2.1.1" + string_scanner: dependency: transitive description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - pdfrx: - dependency: "direct main" - description: - name: pdfrx - sha256: "90747e916a64366b8beb69e9ac175e9134df520110543ab284b839102bf4b7e1" - url: "https://pub.dev" - source: hosted - version: "1.1.29" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - process: - dependency: transitive - description: - name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" - url: "https://pub.dev" - source: hosted - version: "5.0.3" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" - url: "https://pub.dev" - source: hosted - version: "6.1.5" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" - url: "https://pub.dev" - source: hosted - version: "2.5.3" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" - url: "https://pub.dev" - source: hosted - version: "2.4.10" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" - url: "https://pub.dev" - source: hosted - version: "2.5.4" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted version: "1.4.1" @@ -1164,22 +756,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" - sync_http: - dependency: transitive - description: - name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" - source: hosted - version: "0.3.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" - url: "https://pub.dev" - source: hosted - version: "3.3.1" term_glyph: dependency: transitive description: @@ -1196,87 +772,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" - time: - dependency: transitive - description: - name: time - sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - url_launcher: - dependency: transitive - description: - name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" - url: "https://pub.dev" - source: hosted - version: "6.3.1" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" - url: "https://pub.dev" - source: hosted - version: "6.3.16" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" - url: "https://pub.dev" - source: hosted - version: "6.3.3" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - url_launcher_windows: time: dependency: transitive description: @@ -1365,14 +860,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" - vector_math: - dependency: "direct main" - description: - name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" - url: "https://pub.dev" - source: hosted - version: "3.1.4" vector_math: dependency: "direct main" description: @@ -1461,79 +948,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" - watcher: - dependency: transitive - description: - name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webdriver: - dependency: transitive - description: - name: webdriver - sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" - url: "https://pub.dev" - source: hosted - version: "3.0.4" - win32: - dependency: transitive - description: - name: win32 - sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" - url: "https://pub.dev" - source: hosted - version: "5.13.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xxh3: - dependency: transitive - description: - name: xxh3 - sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" sdks: dart: ">=3.7.2 <4.0.0" flutter: ">=3.29.0" - flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index d1c5ae6..5b0460a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,14 +39,6 @@ dependencies: shared_preferences: ^2.5.3 path: ^1.9.1 - pdfrx: ^1.1.28 - path_provider: ^2.1.5 - file_picker: ^10.1.9 - provider: ^6.1.5 - vector_math: ^2.1.4 - shared_preferences: ^2.5.3 - path: ^1.9.1 - # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 @@ -65,9 +57,6 @@ dev_dependencies: integration_test: sdk: flutter - integration_test: - sdk: flutter - # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your @@ -97,13 +86,6 @@ flutter: - assets/sample_ocr_test.pdf - assets/ocr_result.json - assets: - - assets/icons/ - - integration_test/assets/ - - assets/sample.pdf - - assets/sample_ocr_test.pdf - - assets/ocr_result.json - # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images @@ -116,13 +98,6 @@ flutter: # "family" key with the font family name, and a "fonts" key with a # list giving the asset and other descriptors for the font. For # example: - fonts: - - family: Pretendard Variable - fonts: - - asset: assets/fonts/Pretendard-Medium.ttf - weight: 500 - - asset: assets/fonts/Pretendard-SemiBold.ttf - weight: 600 fonts: - family: Pretendard Variable fonts: From 1fbaae3260afb7d9152a79e860993e6d936206d9 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 11:53:20 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat:=20PDF=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=ED=94=BC=EA=B7=9C=EC=96=B4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=ED=8C=9D=EC=97=85=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20PDFDataViewModel=EC=97=90=EC=84=9C=20=ED=94=BC?= =?UTF-8?q?=EA=B7=9C=EC=96=B4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=94=94=EB=B2=84=EA=B9=85=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20PDF=20=EB=B7=B0=EC=96=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=94=BC=EA=B7=9C=EC=96=B4=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=EC=9D=98=20=EC=9C=84=EC=B9=98=20=EB=B0=8F=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EA=B0=9C=EC=84=A0=20-=20FigureOverlayWidget?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A1=9C=EB=94=A9=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=BC=EA=B7=9C=EC=96=B4=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20-=20PopoverWrapper=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=8C=9D=EC=97=85=20=ED=81=AC=EA=B8=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20=EC=A1=B0=EC=A0=95=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B2=BD=ED=97=98=20=ED=96=A5?= =?UTF-8?q?=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pdf_viewer/models/pdf_data_viewmodel.dart | 79 ++++++++-- .../pdf_viewer/screens/pdf_viewer.dart | 56 ++++--- .../widgets/figure_overlay_widget.dart | 141 +++++++++++++++--- .../pdf_viewer/widgets/popover_wrapper.dart | 103 ++++++++----- 4 files changed, 280 insertions(+), 99 deletions(-) diff --git a/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart b/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart index 3143050..3ea54ee 100644 --- a/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart +++ b/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart @@ -98,30 +98,91 @@ class PDFDataViewModel extends ChangeNotifier { } Future getFigureImage(BaseLayout figure) async { - if (figure.type != LayoutType.figure || _pdfDocument == null) return null; + 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) return null; + 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 == pageIndex, + (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}', ); - if (pageDoc == null) return null; + // 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; final left = figureRect.left * scaleX; final top = figureRect.top * scaleY; final width = figureRect.width * scaleX; final height = figureRect.height * scaleY; + print(' Original rect: ${figureRect.toString()}'); + print(' Scaled rect: left=$left, top=$top, width=$width, height=$height'); + + // Validate dimensions + if (width <= 0 || height <= 0) { + print('❌ Invalid dimensions after scaling'); + return null; + } + + // Clamp coordinates to page bounds to prevent rendering errors + final clampedLeft = left.clamp(0.0, pageDoc.width - 1); + final clampedTop = top.clamp(0.0, pageDoc.height - 1); + final clampedWidth = (width).clamp(1.0, pageDoc.width - clampedLeft); + final clampedHeight = (height).clamp(1.0, pageDoc.height - clampedTop); + + if (clampedLeft != left || + clampedTop != top || + clampedWidth != width || + clampedHeight != height) { + print( + '⚠️ Clamped coordinates: left=$clampedLeft, top=$clampedTop, width=$clampedWidth, height=$clampedHeight', + ); + } + final image = await pageDoc.render( - x: left.toInt(), - y: top.toInt(), - width: width.toInt(), - height: height.toInt(), + x: clampedLeft.toInt(), + y: clampedTop.toInt(), + width: clampedWidth.toInt(), + height: clampedHeight.toInt(), ); - if (image == null) return null; + 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/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index cd2e022..bd14977 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -21,6 +21,7 @@ class _PDFViewerState extends State { PDFDataViewModel? _viewModel; final PdfViewerController _pdfController = PdfViewerController(); bool _sidebarVisible = true; + OverlayEntry? _currentPopover; @override void initState() { @@ -55,56 +56,42 @@ class _PDFViewerState extends State { _pdfController.goToPage(pageNumber: pageNumber); } - void _showFigurePopover(BaseLayout reference) { - final RenderBox? renderBox = context.findRenderObject() as RenderBox?; - if (renderBox == null) return; + void _showFigurePopover(BaseLayout reference, Offset tapPosition) { + // Dismiss any existing popover + _dismissCurrentPopover(); - // Get the position where the tap occurred final overlay = Overlay.of(context); - final pageNumber = _viewModel!.getPageNumberForReference(reference); - if (pageNumber == null) return; - - // Calculate position based on the reference location - final page = _viewModel!.pages[pageNumber]; - final pageRect = Rect.fromLTWH( - 0, - 0, - page.width.toDouble(), - page.height.toDouble(), - ); // TODO: FIXXXX - if (pageRect == null) return; - - final scaleX = pageRect.width / page.width.toDouble(); - final scaleY = pageRect.height / page.height.toDouble(); - - final referenceCenter = Offset( - pageRect.left + (reference.rect.center.dx * scaleX), - pageRect.top + (reference.rect.center.dy * scaleY), - ); final overlayEntry = OverlayEntry( builder: (context) => PopoverWrapper( - targetPosition: referenceCenter, - onDismiss: () {}, + targetPosition: tapPosition, + onDismiss: _dismissCurrentPopover, child: FigureOverlayWidget( reference: reference, viewModel: _viewModel!, - onClose: () {}, + onClose: _dismissCurrentPopover, navigateToFigure: (figure) { + _dismissCurrentPopover(); _navigateToFigure(figure); }, ), ), ); + _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); + _pdfController.goToPage(pageNumber: pageNumber + 1); } else { // Show error message if figure page not found ScaffoldMessenger.of(context).showSnackBar( @@ -124,6 +111,7 @@ class _PDFViewerState extends State { @override void dispose() { + _dismissCurrentPopover(); _viewModel?.dispose(); super.dispose(); } @@ -319,9 +307,17 @@ class _PDFViewerState extends State { builder: (innerContext) { final theme = Theme.of(innerContext); return GestureDetector( - onTap: () { + onTapDown: (details) { print('Tapped reference: ${reference.content}'); - _onFigureSelected(reference); + // Get the global position of the tap + final RenderBox? renderBox = + innerContext.findRenderObject() as RenderBox?; + if (renderBox != null) { + final globalPosition = renderBox.localToGlobal( + details.localPosition, + ); + _showFigurePopover(reference, globalPosition); + } }, child: Container( decoration: BoxDecoration( diff --git a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart index 1ab527d..2fd8823 100644 --- a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart +++ b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart @@ -19,6 +19,7 @@ class FigureOverlayWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); // Find the actual figure that this reference points to final targetFigure = viewModel.findFigureByReference(reference); if (targetFigure == null) return _buildNotFoundContent(context); @@ -26,19 +27,64 @@ class FigureOverlayWidget extends StatelessWidget { return FutureBuilder( future: viewModel.getFigureImage(targetFigure), builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data != null) { + 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: [ - Padding( + // Figure image + Container( + constraints: const BoxConstraints( + maxHeight: 180, + minHeight: 100, + ), padding: const EdgeInsets.all(8), - child: RawImage(image: snapshot.data!), + child: LayoutBuilder( + builder: (context, constraints) { + return Container( + width: double.infinity, + height: constraints.maxHeight, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: RawImage( + image: snapshot.data!, + fit: BoxFit.contain, + alignment: Alignment.center, + width: constraints.maxWidth, + height: constraints.maxHeight, + ), + ), + ); + }, + ), ), - TextButton( - onPressed: () { - navigateToFigure?.call(targetFigure); - }, - child: const Text('Go to figure'), + // Action button + Container( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + 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), + ), + ), + ), + ), ), ], ), @@ -50,25 +96,72 @@ class FigureOverlayWidget extends StatelessWidget { ); } + 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 Column( - children: [ - Icon( - Icons.image_not_supported, - size: 48, - color: theme.colorScheme.outline, - ), - const SizedBox(height: 16), - Text('Figure not found', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text( - 'The referenced figure could not be located in the document.', - style: theme.textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ], + 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: 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/popover_wrapper.dart b/lib/features/pdf_viewer/widgets/popover_wrapper.dart index cfa0577..09ee350 100644 --- a/lib/features/pdf_viewer/widgets/popover_wrapper.dart +++ b/lib/features/pdf_viewer/widgets/popover_wrapper.dart @@ -16,30 +16,37 @@ class PopoverWrapper extends StatelessWidget { Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; final theme = Theme.of(context); - + // Calculate popover position - const popoverWidth = 280.0; - const popoverHeight = 200.0; - const arrowSize = 8.0; - + const popoverWidth = 300.0; + const popoverMaxHeight = 320.0; + const arrowSize = 10.0; + const margin = 20.0; + // Determine if popover should appear above or below the target - final showAbove = targetPosition.dy > screenSize.height / 2; - + 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 < 16) left = 16; - if (left + popoverWidth > screenSize.width - 16) { - left = screenSize.width - popoverWidth - 16; + if (left < margin) left = margin; + if (left + popoverWidth > screenSize.width - margin) { + left = screenSize.width - popoverWidth - margin; } - - // Calculate vertical position - final top = showAbove - ? targetPosition.dy - popoverHeight - arrowSize - 8 - : targetPosition.dy + arrowSize + 8; - - // Calculate arrow position relative to popover - final arrowLeft = (targetPosition.dx - left).clamp(arrowSize, popoverWidth - arrowSize); - + + // 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( @@ -56,25 +63,33 @@ class PopoverWrapper extends StatelessWidget { onTap: () {}, // Prevent tap from bubbling up child: Container( width: popoverWidth, - height: popoverHeight, + constraints: const BoxConstraints( + maxHeight: popoverMaxHeight, + ), decoration: BoxDecoration( color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(12), + 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.15), - blurRadius: 20, - offset: const Offset(0, 8), + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 30, + offset: const Offset(0, 12), + spreadRadius: 0, ), BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 6, - offset: const Offset(0, 2), + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 10, + offset: const Offset(0, 4), + spreadRadius: 0, ), ], ), child: ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), child: child, ), ), @@ -83,11 +98,14 @@ class PopoverWrapper extends StatelessWidget { // Arrow Positioned( left: left + arrowLeft - arrowSize, - top: showAbove ? top + popoverHeight : top - 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, ), ), @@ -102,18 +120,30 @@ class PopoverWrapper extends StatelessWidget { class _ArrowPainter extends CustomPainter { final Color color; + final Color borderColor; final bool pointsUp; - _ArrowPainter({required this.color, required this.pointsUp}); + _ArrowPainter({ + required this.color, + required this.borderColor, + required this.pointsUp, + }); @override void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..style = PaintingStyle.fill; + 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); @@ -125,9 +155,10 @@ class _ArrowPainter extends CustomPainter { } path.close(); - canvas.drawPath(path, paint); + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, borderPaint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} \ No newline at end of file +} From 030800caec6846c2d6d72b87f4ef3c0b3b155628 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:05:49 +0900 Subject: [PATCH 12/17] =?UTF-8?q?feat:=20PDF=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=ED=94=BC=EA=B7=9C=EC=96=B4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=B0=8F=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20PDFDataViewModel=EC=97=90=EC=84=9C=20=ED=94=BC?= =?UTF-8?q?=EA=B7=9C=EC=96=B4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EB=A1=9C=EC=A7=81=EC=97=90=20=ED=8C=A8?= =?UTF-8?q?=EB=94=A9=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=81=B4=EB=9E=A8?= =?UTF-8?q?=ED=95=91=20=EA=B0=9C=EC=84=A0=20-=20FigureOverlayWidget?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=ED=81=AC=EA=B8=B0=20=EC=B5=9C=EC=A0=81=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B2=84=ED=8A=BC=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20-=20PopoverWrapper=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EC=B5=9C=EB=8C=80=20=EB=86=92=EC=9D=B4=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B2=BD=ED=97=98=20=ED=96=A5=EC=83=81=20-=20PDFSi?= =?UTF-8?q?debar=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94?= =?UTF-8?q?=20=EB=B0=B0=EA=B2=BD=EC=83=89=EC=9D=84=20=ED=85=8C=EB=A7=88?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pdf_viewer/models/pdf_data_viewmodel.dart | 66 ++++++++----- .../pdf_viewer/screens/pdf_viewer.dart | 1 + .../widgets/figure_overlay_widget.dart | 93 ++++++++++++++----- .../pdf_viewer/widgets/popover_wrapper.dart | 2 +- .../widgets/sidebar/pdf_sidebar.dart | 1 + 5 files changed, 120 insertions(+), 43 deletions(-) diff --git a/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart b/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart index 3ea54ee..e8164cd 100644 --- a/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart +++ b/lib/features/pdf_viewer/models/pdf_data_viewmodel.dart @@ -142,40 +142,64 @@ class PDFDataViewModel extends ChangeNotifier { print(' Scale factors: scaleX=$scaleX, scaleY=$scaleY'); final figureRect = figure.rect; - final left = figureRect.left * scaleX; - final top = figureRect.top * scaleY; - final width = figureRect.width * scaleX; - final height = figureRect.height * scaleY; + + // 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(' Scaled rect: left=$left, top=$top, width=$width, height=$height'); + print( + ' Padded rect: left=$paddedLeft, top=$paddedTop, width=$paddedWidth, height=$paddedHeight', + ); // Validate dimensions - if (width <= 0 || height <= 0) { - print('❌ Invalid dimensions after scaling'); + if (paddedWidth <= 0 || paddedHeight <= 0) { + print('❌ Invalid dimensions after scaling and padding'); return null; } - // Clamp coordinates to page bounds to prevent rendering errors - final clampedLeft = left.clamp(0.0, pageDoc.width - 1); - final clampedTop = top.clamp(0.0, pageDoc.height - 1); - final clampedWidth = (width).clamp(1.0, pageDoc.width - clampedLeft); - final clampedHeight = (height).clamp(1.0, pageDoc.height - clampedTop); + // 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; - if (clampedLeft != left || - clampedTop != top || - clampedWidth != width || - clampedHeight != height) { + // 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( - '⚠️ Clamped coordinates: left=$clampedLeft, top=$clampedTop, width=$clampedWidth, height=$clampedHeight', + '⚠️ 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: clampedLeft.toInt(), - y: clampedTop.toInt(), - width: clampedWidth.toInt(), - height: clampedHeight.toInt(), + x: renderX, + y: renderY, + width: renderWidth, + height: renderHeight, ); if (image == null) { print('❌ Failed to render image'); diff --git a/lib/features/pdf_viewer/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index bd14977..e2ba509 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -38,6 +38,7 @@ class _PDFViewerState extends State { _pdfController.useDocument((doc) { _viewModel?.loadPDFData(widget.path, doc); }); + setState(() {}); } }); }); diff --git a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart index 2fd8823..c2302e5 100644 --- a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart +++ b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart @@ -37,16 +37,48 @@ class FigureOverlayWidget extends StatelessWidget { children: [ // Figure image Container( - constraints: const BoxConstraints( - maxHeight: 180, - minHeight: 100, - ), padding: const EdgeInsets.all(8), child: LayoutBuilder( builder: (context, constraints) { + final image = snapshot.data!; + final imageAspectRatio = image.width / image.height; + + // Calculate optimal display size completely based on image aspect ratio + final availableWidth = + constraints.maxWidth - 16; // Account for padding + final availableHeight = + MediaQuery.of(context).size.height * + 0.4; // Max 40% of screen height + + double displayWidth; + double displayHeight; + + // Calculate size that fits within available space while maintaining aspect ratio + final widthBasedHeight = + availableWidth / imageAspectRatio; + final heightBasedWidth = + availableHeight * imageAspectRatio; + + if (widthBasedHeight <= availableHeight) { + // Width is the limiting factor + displayWidth = availableWidth; + displayHeight = widthBasedHeight; + } else { + // Height is the limiting factor + displayWidth = heightBasedWidth; + displayHeight = availableHeight; + } + + // Only apply minimal constraints to prevent unusable sizes + displayWidth = displayWidth.clamp(100.0, availableWidth); + displayHeight = displayHeight.clamp( + 40.0, + availableHeight, + ); + return Container( - width: double.infinity, - height: constraints.maxHeight, + width: displayWidth, + height: displayHeight, decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest .withValues(alpha: 0.3), @@ -55,11 +87,11 @@ class FigureOverlayWidget extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(8), child: RawImage( - image: snapshot.data!, - fit: BoxFit.contain, + image: image, + fit: + BoxFit + .contain, // Show entire image without cropping alignment: Alignment.center, - width: constraints.maxWidth, - height: constraints.maxHeight, ), ), ); @@ -71,18 +103,37 @@ class FigureOverlayWidget extends StatelessWidget { padding: const EdgeInsets.all(16), child: SizedBox( width: double.infinity, - child: FilledButton.icon( - onPressed: () { - 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), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.tonalIcon( + onPressed: () { + 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), + ), + ), + ), + FilledButton.icon( + onPressed: () { + navigateToFigure?.call(targetFigure); + }, + icon: const Icon(Icons.search, size: 18), + label: const Text('Ask to AI'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), ), - ), + ], ), ), ), diff --git a/lib/features/pdf_viewer/widgets/popover_wrapper.dart b/lib/features/pdf_viewer/widgets/popover_wrapper.dart index 09ee350..108d29b 100644 --- a/lib/features/pdf_viewer/widgets/popover_wrapper.dart +++ b/lib/features/pdf_viewer/widgets/popover_wrapper.dart @@ -19,7 +19,7 @@ class PopoverWrapper extends StatelessWidget { // Calculate popover position const popoverWidth = 300.0; - const popoverMaxHeight = 320.0; + const popoverMaxHeight = 450.0; const arrowSize = 10.0; const margin = 20.0; diff --git a/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart b/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart index 36ea506..e79aae7 100644 --- a/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart +++ b/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart @@ -36,6 +36,7 @@ class _PDFSideBarState extends State { Widget build(BuildContext context) { return Container( width: _sidebarWidth, + color: Theme.of(context).colorScheme.surfaceVariant, child: Column( children: [ SegmentedButton<_PDFSideBarTab>( From 1a151865429b77cfbccc576a40096c0080c9307e Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:40:59 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20AI=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=ED=98=84=20-=20PDF=20=EB=B7=B0=EC=96=B4?= =?UTF-8?q?=EC=97=90=20AI=20=EC=B1=84=ED=8C=85=20=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20-=20AI=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=99=94=EB=A9=B4=EC=9D=84=20=ED=86=B5=ED=95=B4=20?= =?UTF-8?q?API=20=ED=82=A4=20=EB=B0=8F=20=EB=AA=A8=EB=8D=B8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20-=20AI=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20PDF=20=EB=B7=B0=EC=96=B4=EC=97=90=EC=84=9C=20AI=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20FigureOverlayWidget=EC=97=90=EC=84=9C?= =?UTF-8?q?=20AI=20=EC=9A=94=EC=B2=AD=20=EB=B2=84=ED=8A=BC=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pdf_viewer/screens/pdf_viewer.dart | 46 ++ .../pdf_viewer/widgets/ai_chat_popup.dart | 492 ++++++++++++++++++ .../widgets/figure_overlay_widget.dart | 10 +- lib/features/settings/ai_settings_screen.dart | 294 +++++++++++ lib/main.dart | 4 + .../services/ai_service/ai_service.dart | 204 ++++++++ .../ai_service/ai_service_export.dart | 2 + .../ai_service/models/ai_provider.dart | 49 ++ 8 files changed, 1097 insertions(+), 4 deletions(-) create mode 100644 lib/features/pdf_viewer/widgets/ai_chat_popup.dart create mode 100644 lib/features/settings/ai_settings_screen.dart create mode 100644 lib/shared/services/ai_service/ai_service.dart create mode 100644 lib/shared/services/ai_service/ai_service_export.dart create mode 100644 lib/shared/services/ai_service/models/ai_provider.dart diff --git a/lib/features/pdf_viewer/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index e2ba509..7f80319 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -4,6 +4,8 @@ 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/ai_chat_popup.dart'; +import 'package:snapfig/shared/services/ai_service/ai_service.dart'; import '../../../shared/services/pdf_core/pdf_core.dart'; import '../widgets/pdf_bottom_bar.dart'; @@ -27,6 +29,9 @@ class _PDFViewerState extends State { 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; @@ -76,6 +81,10 @@ class _PDFViewerState extends State { _dismissCurrentPopover(); _navigateToFigure(figure); }, + onAskAI: (figure) { + _dismissCurrentPopover(); + _showAIChatPopup(figure); + }, ), ), ); @@ -84,6 +93,43 @@ class _PDFViewerState extends State { overlay.insert(overlayEntry); } + void _showAIChatPopup(BaseLayout figure) { + // Dismiss any existing popover + _dismissCurrentPopover(); + + final overlay = Overlay.of(context); + + final overlayEntry = OverlayEntry( + builder: (context) => Material( + color: Colors.transparent, + child: Stack( + children: [ + // Background overlay + GestureDetector( + onTap: _dismissCurrentPopover, + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withValues(alpha: 0.5), + ), + ), + // Centered AI chat popup + Center( + child: AIChatPopup( + figure: figure, + viewModel: _viewModel!, + onClose: _dismissCurrentPopover, + ), + ), + ], + ), + ), + ); + + _currentPopover = overlayEntry; + overlay.insert(overlayEntry); + } + void _dismissCurrentPopover() { _currentPopover?.remove(); _currentPopover = null; diff --git a/lib/features/pdf_viewer/widgets/ai_chat_popup.dart b/lib/features/pdf_viewer/widgets/ai_chat_popup.dart new file mode 100644 index 0000000..11a6ae0 --- /dev/null +++ b/lib/features/pdf_viewer/widgets/ai_chat_popup.dart @@ -0,0 +1,492 @@ +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 AIChatPopup extends StatefulWidget { + final BaseLayout figure; + final PDFDataViewModel viewModel; + final VoidCallback onClose; + + const AIChatPopup({ + super.key, + required this.figure, + required this.viewModel, + required this.onClose, + }); + + @override + State createState() => _AIChatPopupState(); +} + +class _AIChatPopupState extends State { + final List _messages = []; + final TextEditingController _textController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + AIProvider? _selectedProvider; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _loadAvailableProviders(); + _addWelcomeMessage(); + _sendInitialFigureMessage(); + } + + 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; + + if (configurations.isEmpty) { + return _buildNoConfigurationView(context); + } + + return Container( + width: 400, + height: 500, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + children: [ + _buildHeader(context), + Expanded(child: _buildMessageList(context)), + _buildInputSection(context), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + final theme = Theme.of(context); + final configurations = AIService.instance.configurations; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Icon(Icons.smart_toy, color: theme.colorScheme.primary, size: 20), + const SizedBox(width: 8), + Text('AI Chat', style: theme.textTheme.titleMedium), + const Spacer(), + if (configurations.length > 1) + DropdownButton( + value: _selectedProvider, + underline: const SizedBox(), + 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: widget.onClose, + icon: const Icon(Icons.close, size: 18), + 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) { + final theme = Theme.of(context); + + return Container( + width: 350, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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: widget.onClose, + child: const Text('Close'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: () { + widget.onClose(); + Navigator.of(context).pushNamed('/ai-settings'); + }, + child: const Text('Settings'), + ), + ), + ], + ), + ], + ), + ); + } + + 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, + ); + } + }); + } + + @override + void dispose() { + _textController.dispose(); + _scrollController.dispose(); + super.dispose(); + } +} diff --git a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart index c2302e5..cccaec9 100644 --- a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart +++ b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:pdfrx/pdfrx.dart'; import 'package:snapfig/features/pdf_viewer/models/pdf_data_viewmodel.dart'; import 'package:snapfig/shared/services/pdf_core/models/models.dart'; @@ -8,6 +7,7 @@ class FigureOverlayWidget extends StatelessWidget { final PDFDataViewModel viewModel; final VoidCallback onClose; final void Function(BaseLayout figure)? navigateToFigure; + final void Function(BaseLayout figure)? onAskAI; const FigureOverlayWidget({ super.key, @@ -15,6 +15,7 @@ class FigureOverlayWidget extends StatelessWidget { required this.viewModel, required this.onClose, this.navigateToFigure, + this.onAskAI, }); @override @@ -120,12 +121,13 @@ class FigureOverlayWidget extends StatelessWidget { ), ), ), + const SizedBox(height: 8), FilledButton.icon( onPressed: () { - navigateToFigure?.call(targetFigure); + onAskAI?.call(targetFigure); }, - icon: const Icon(Icons.search, size: 18), - label: const Text('Ask to AI'), + icon: const Icon(Icons.smart_toy, size: 18), + label: const Text('Ask AI'), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( 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 37e1275..e3b3977 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.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'; @@ -39,6 +40,9 @@ 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 From eb5781c3d35dfc128475e5e1021ce11bdfb99833 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:46:38 +0900 Subject: [PATCH 14/17] =?UTF-8?q?feat:=20AI=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=99=94=EB=A9=B4=20=EC=97=B0=EA=B2=B0=20-=20PDF?= =?UTF-8?q?=20=EB=B7=B0=EC=96=B4=EC=97=90=20AI=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=9D=84=20SimpleDraggableAIChat=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EC=97=AC=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=9C=EC=84=A0=20-=20AI=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=EC=9D=98=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EA=B8=B0=EC=A1=B4=20AIChatPopup=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC=20-=20PDF=20=EB=B7=B0=EC=96=B4?= =?UTF-8?q?=EC=9D=98=20UI=20=EC=9A=94=EC=86=8C=EC=97=90=EC=84=9C=20AI=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B2=84=ED=8A=BC=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=8F=20=EC=9C=84=EC=B9=98=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/home/screens/home_widget.dart | 27 + .../pdf_viewer/screens/pdf_viewer.dart | 64 +- .../pdf_viewer/widgets/ai_chat_popup.dart | 492 ------------ .../pdf_viewer/widgets/pdf_bottom_bar.dart | 2 +- .../widgets/simple_draggable_ai_chat.dart | 754 ++++++++++++++++++ lib/main.dart | 4 +- 6 files changed, 820 insertions(+), 523 deletions(-) delete mode 100644 lib/features/pdf_viewer/widgets/ai_chat_popup.dart create mode 100644 lib/features/pdf_viewer/widgets/simple_draggable_ai_chat.dart 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/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index 7f80319..0c96019 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -4,7 +4,7 @@ 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/ai_chat_popup.dart'; +import 'package:snapfig/features/pdf_viewer/widgets/simple_draggable_ai_chat.dart'; import 'package:snapfig/shared/services/ai_service/ai_service.dart'; import '../../../shared/services/pdf_core/pdf_core.dart'; @@ -98,32 +98,24 @@ class _PDFViewerState extends State { _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) => Material( - color: Colors.transparent, - child: Stack( - children: [ - // Background overlay - GestureDetector( - onTap: _dismissCurrentPopover, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.black.withValues(alpha: 0.5), - ), - ), - // Centered AI chat popup - Center( - child: AIChatPopup( - figure: figure, - viewModel: _viewModel!, - onClose: _dismissCurrentPopover, - ), - ), - ], - ), - ), + builder: + (context) => SimpleDraggableAIChat( + figure: figure, + viewModel: _viewModel!, + onClose: _dismissCurrentPopover, + initialPosition: initialPosition, + ), ); _currentPopover = overlayEntry; @@ -174,7 +166,18 @@ class _PDFViewerState extends State { listenable: _viewModel!, builder: (context, child) { 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( @@ -211,6 +214,13 @@ class _PDFViewerState extends State { ], ), ), + // 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), @@ -293,7 +303,7 @@ class _PDFViewerState extends State { final page = _viewModel!.pages[pageIndex]; if (layouts == null) return []; final references = layouts.where( - (layout) => layout.type == LayoutType.figureReference, + (layout) => layout.type == LayoutType.figure, ); // 페이지 크기 비율 계산 - OCR 좌표를 뷰어 좌표로 변환 diff --git a/lib/features/pdf_viewer/widgets/ai_chat_popup.dart b/lib/features/pdf_viewer/widgets/ai_chat_popup.dart deleted file mode 100644 index 11a6ae0..0000000 --- a/lib/features/pdf_viewer/widgets/ai_chat_popup.dart +++ /dev/null @@ -1,492 +0,0 @@ -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 AIChatPopup extends StatefulWidget { - final BaseLayout figure; - final PDFDataViewModel viewModel; - final VoidCallback onClose; - - const AIChatPopup({ - super.key, - required this.figure, - required this.viewModel, - required this.onClose, - }); - - @override - State createState() => _AIChatPopupState(); -} - -class _AIChatPopupState extends State { - final List _messages = []; - final TextEditingController _textController = TextEditingController(); - final ScrollController _scrollController = ScrollController(); - AIProvider? _selectedProvider; - bool _isLoading = false; - - @override - void initState() { - super.initState(); - _loadAvailableProviders(); - _addWelcomeMessage(); - _sendInitialFigureMessage(); - } - - 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; - - if (configurations.isEmpty) { - return _buildNoConfigurationView(context); - } - - return Container( - width: 400, - height: 500, - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - children: [ - _buildHeader(context), - Expanded(child: _buildMessageList(context)), - _buildInputSection(context), - ], - ), - ); - } - - Widget _buildHeader(BuildContext context) { - final theme = Theme.of(context); - final configurations = AIService.instance.configurations; - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - child: Row( - children: [ - Icon(Icons.smart_toy, color: theme.colorScheme.primary, size: 20), - const SizedBox(width: 8), - Text('AI Chat', style: theme.textTheme.titleMedium), - const Spacer(), - if (configurations.length > 1) - DropdownButton( - value: _selectedProvider, - underline: const SizedBox(), - 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: widget.onClose, - icon: const Icon(Icons.close, size: 18), - 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) { - final theme = Theme.of(context); - - return Container( - width: 350, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - 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: widget.onClose, - child: const Text('Close'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FilledButton( - onPressed: () { - widget.onClose(); - Navigator.of(context).pushNamed('/ai-settings'); - }, - child: const Text('Settings'), - ), - ), - ], - ), - ], - ), - ); - } - - 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, - ); - } - }); - } - - @override - void dispose() { - _textController.dispose(); - _scrollController.dispose(); - super.dispose(); - } -} 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/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/main.dart b/lib/main.dart index e3b3977..cc5ba15 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,9 +40,7 @@ class SnapfigApp extends StatelessWidget { darkTheme: darkTheme, navigatorKey: _navigationService.navigatorKey, home: const HomeWidget(), - routes: { - '/ai-settings': (context) => const AISettingsScreen(), - }, + routes: {'/ai-settings': (context) => const AISettingsScreen()}, ), ); } From 72e2c09ba050b0e863c7fe79af44edb237816802 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:51:33 +0900 Subject: [PATCH 15/17] fix: Hide api base url --- .gitignore | 3 +++ integration_test/ocr_provider_test.dart | 4 ++-- integration_test/pdf_provider_test.dart | 5 +++-- lib/main.dart | 6 +++--- pubspec.lock | 8 ++++++++ pubspec.yaml | 1 + 6 files changed, 20 insertions(+), 7 deletions(-) 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 eef2f48..fc89c55 100644 --- a/integration_test/ocr_provider_test.dart +++ b/integration_test/ocr_provider_test.dart @@ -28,8 +28,8 @@ void main() { final pdfPath = await copyAssetToLocal('integration_test/assets/test.pdf'); final result = await provider.process(pdfPath); expect(result, isA()); - expect(result.paragraphData.pages, isNotEmpty); - expect(result.interactiveElements, isNotEmpty); + expect(result.pages, 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 93f1dfd..17949a0 100644 --- a/integration_test/pdf_provider_test.dart +++ b/integration_test/pdf_provider_test.dart @@ -21,8 +21,9 @@ class DummyOcrService implements OCRProvider { @override Future process(String pdfPath) async { return const OCRResult( - interactiveElements: [], - paragraphData: ParagraphData(pages: []), + metadata: Metadata(title: 'test', author: 'test', pages: 0), + pages: [], + figures: [], ); } } diff --git a/lib/main.dart b/lib/main.dart index cc5ba15..6143671 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ // 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'; @@ -13,11 +14,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://ce7e-165-194-27-212.ngrok-free.app', - ), + ocrProvider: OCRProviderImpl(baseUrl: dotenv.env['OCR_BASE_URL']!), dbPath: dir.path, ); runApp(SnapfigApp(pdfProvider: pdfProvider)); diff --git a/pubspec.lock b/pubspec.lock index 9b28281..78d4f58 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -246,6 +246,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 diff --git a/pubspec.yaml b/pubspec.yaml index 5b0460a..afb66b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: http: ^1.4.0 flutter_client_sse: ^2.0.3 + flutter_dotenv: ^5.1.0 dev_dependencies: flutter_test: From 1993bd677c86000ba75aa65ddebb399595405d00 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 17:09:39 +0900 Subject: [PATCH 16/17] =?UTF-8?q?feat:=20PDF=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=ED=94=BC=EA=B7=9C=EC=96=B4=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EC=9C=84=EC=A0=AF=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8F=99=EC=A0=81=20=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=95?= =?UTF-8?q?=20-=20FigureOverlayWidget=EC=9D=84=20StatelessWidget=EC=97=90?= =?UTF-8?q?=EC=84=9C=20StatefulWidget=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20-=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=91=9C=EC=8B=9C=20=EC=8B=9C=20=ED=99=95=EB=8C=80?= =?UTF-8?q?/=EC=B6=95=EC=86=8C=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20Interactive?= =?UTF-8?q?Viewer=EB=A1=9C=20=EA=B5=AC=ED=98=84=20-=20PopoverWrapper?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8F=99=EC=A0=81=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B2=BD=ED=97=98=20?= =?UTF-8?q?=ED=96=A5=EC=83=81=20-=20PDF=20=EB=B7=B0=EC=96=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=ED=83=80=EC=9E=85=EC=9D=84=20figureReference?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pdf_viewer/screens/pdf_viewer.dart | 2 +- .../widgets/figure_overlay_widget.dart | 77 +++++++++++-------- .../pdf_viewer/widgets/popover_wrapper.dart | 12 ++- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/lib/features/pdf_viewer/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index 0c96019..2e72ac8 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -303,7 +303,7 @@ class _PDFViewerState extends State { final page = _viewModel!.pages[pageIndex]; if (layouts == null) return []; final references = layouts.where( - (layout) => layout.type == LayoutType.figure, + (layout) => layout.type == LayoutType.figureReference, ); // 페이지 크기 비율 계산 - OCR 좌표를 뷰어 좌표로 변환 diff --git a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart index cccaec9..b9f4fd2 100644 --- a/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart +++ b/lib/features/pdf_viewer/widgets/figure_overlay_widget.dart @@ -2,7 +2,7 @@ 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 StatelessWidget { +class FigureOverlayWidget extends StatefulWidget { final BaseLayout reference; final PDFDataViewModel viewModel; final VoidCallback onClose; @@ -18,15 +18,28 @@ class FigureOverlayWidget extends StatelessWidget { 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 = viewModel.findFigureByReference(reference); + final targetFigure = widget.viewModel.findFigureByReference(widget.reference); if (targetFigure == null) return _buildNotFoundContent(context); return FutureBuilder( - future: viewModel.getFigureImage(targetFigure), + future: widget.viewModel.getFigureImage(targetFigure), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return _buildLoadingContent(context); @@ -36,7 +49,7 @@ class FigureOverlayWidget extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Figure image + // Figure image with zoom controls Container( padding: const EdgeInsets.all(8), child: LayoutBuilder( @@ -44,38 +57,34 @@ class FigureOverlayWidget extends StatelessWidget { final image = snapshot.data!; final imageAspectRatio = image.width / image.height; - // Calculate optimal display size completely based on image aspect ratio - final availableWidth = - constraints.maxWidth - 16; // Account for padding - final availableHeight = - MediaQuery.of(context).size.height * - 0.4; // Max 40% of screen 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; - // Calculate size that fits within available space while maintaining aspect ratio - final widthBasedHeight = - availableWidth / imageAspectRatio; - final heightBasedWidth = - availableHeight * imageAspectRatio; + final widthBasedHeight = availableWidth / imageAspectRatio; + final heightBasedWidth = availableHeight * imageAspectRatio; if (widthBasedHeight <= availableHeight) { - // Width is the limiting factor displayWidth = availableWidth; displayHeight = widthBasedHeight; } else { - // Height is the limiting factor displayWidth = heightBasedWidth; displayHeight = availableHeight; } - // Only apply minimal constraints to prevent unusable sizes - displayWidth = displayWidth.clamp(100.0, availableWidth); - displayHeight = displayHeight.clamp( - 40.0, - 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, @@ -87,12 +96,16 @@ class FigureOverlayWidget extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: RawImage( - image: image, - fit: - BoxFit - .contain, // Show entire image without cropping - alignment: Alignment.center, + child: InteractiveViewer( + transformationController: _transformationController, + minScale: 0.5, + maxScale: 4.0, + constrained: true, + child: RawImage( + image: image, + fit: BoxFit.contain, + alignment: Alignment.center, + ), ), ), ); @@ -110,7 +123,7 @@ class FigureOverlayWidget extends StatelessWidget { children: [ FilledButton.tonalIcon( onPressed: () { - navigateToFigure?.call(targetFigure); + widget.navigateToFigure?.call(targetFigure); }, icon: const Icon(Icons.open_in_new, size: 18), label: const Text('Go to Figure'), @@ -124,7 +137,7 @@ class FigureOverlayWidget extends StatelessWidget { const SizedBox(height: 8), FilledButton.icon( onPressed: () { - onAskAI?.call(targetFigure); + widget.onAskAI?.call(targetFigure); }, icon: const Icon(Icons.smart_toy, size: 18), label: const Text('Ask AI'), @@ -203,7 +216,7 @@ class FigureOverlayWidget extends StatelessWidget { ), const SizedBox(height: 16), OutlinedButton.icon( - onPressed: onClose, + onPressed: widget.onClose, icon: const Icon(Icons.close, size: 16), label: const Text('Close'), style: OutlinedButton.styleFrom( diff --git a/lib/features/pdf_viewer/widgets/popover_wrapper.dart b/lib/features/pdf_viewer/widgets/popover_wrapper.dart index 108d29b..230d510 100644 --- a/lib/features/pdf_viewer/widgets/popover_wrapper.dart +++ b/lib/features/pdf_viewer/widgets/popover_wrapper.dart @@ -4,12 +4,16 @@ 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 @@ -17,9 +21,9 @@ class PopoverWrapper extends StatelessWidget { final screenSize = MediaQuery.of(context).size; final theme = Theme.of(context); - // Calculate popover position - const popoverWidth = 300.0; - const popoverMaxHeight = 450.0; + // 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; @@ -63,7 +67,7 @@ class PopoverWrapper extends StatelessWidget { onTap: () {}, // Prevent tap from bubbling up child: Container( width: popoverWidth, - constraints: const BoxConstraints( + constraints: BoxConstraints( maxHeight: popoverMaxHeight, ), decoration: BoxDecoration( From f21e66ad1f8e7d8219776e76501bc72853407557 Mon Sep 17 00:00:00 2001 From: dev_jaeho <33758013+jaeho0718@users.noreply.github.com> Date: Fri, 13 Jun 2025 17:46:50 +0900 Subject: [PATCH 17/17] =?UTF-8?q?feat:=20PDF=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=ED=94=BC=EA=B7=9C=EC=96=B4=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9C=84=EC=A0=AF=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20-=20Figur?= =?UTF-8?q?eHighlightWidget=EC=9D=84=20=EC=83=88=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=98=EC=97=AC=20=ED=94=BC=EA=B7=9C=EC=96=B4=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=BC=EA=B7=9C=EC=96=B4=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?-=20PDF=20=EB=B7=B0=EC=96=B4=EC=97=90=EC=84=9C=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=B0=B8=EC=A1=B0=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20=ED=94=BC=EA=B7=9C=EC=96=B4=EC=99=80=20?= =?UTF-8?q?=ED=94=BC=EA=B7=9C=EC=96=B4=20=EC=B0=B8=EC=A1=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20=ED=8F=AC=ED=95=A8=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=9C=84=EC=A0=AF=EC=97=90=EC=84=9C=20AI=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=ED=9A=A8=EA=B3=BC=20=EA=B5=AC=ED=98=84=20-=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20OCR=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B8=ED=84=B0=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pdf_viewer/screens/pdf_viewer.dart | 27 +- .../widgets/figure_highlight_widget.dart | 381 ++++++++++++++++++ .../widgets/ocr_overlay_painter.dart | 0 3 files changed, 394 insertions(+), 14 deletions(-) create mode 100644 lib/features/pdf_viewer/widgets/figure_highlight_widget.dart delete mode 100644 lib/features/pdf_viewer/widgets/ocr_overlay_painter.dart diff --git a/lib/features/pdf_viewer/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index 2e72ac8..eb33eaa 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -5,6 +5,7 @@ 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'; @@ -302,8 +303,11 @@ class _PDFViewerState extends State { 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) => + layout.type == LayoutType.figureReference || + layout.type == LayoutType.figure, ); // 페이지 크기 비율 계산 - OCR 좌표를 뷰어 좌표로 변환 @@ -362,26 +366,21 @@ class _PDFViewerState extends State { Widget _buildHighlightWidget(BuildContext context, BaseLayout reference) { return Builder( builder: (innerContext) { - final theme = Theme.of(innerContext); - return GestureDetector( - onTapDown: (details) { - print('Tapped reference: ${reference.content}'); + 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( - details.localPosition, - ); + final globalPosition = renderBox.localToGlobal(Offset.zero); _showFigurePopover(reference, globalPosition); } }, - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(2.0), - ), - ), + onAskAI: () { + print('Ask AI for figure: ${reference.content}'); + _showAIChatPopup(reference); + }, ); }, ); 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/ocr_overlay_painter.dart b/lib/features/pdf_viewer/widgets/ocr_overlay_painter.dart deleted file mode 100644 index e69de29..0000000